diff --git a/.editorconfig b/.editorconfig index 3fa1ddd2..8b3044ec 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,13 +25,12 @@ indent_size = 2 [*.{yml,yaml}] indent_size = 2 -# PowerShell files inherit LF + UTF-8 (no BOM) + 4-space indent from -# the global [*] section above — no [*.ps1] override needed. The -# `charset = utf-8` setting is what prevents editors from writing a -# BOM, which together with the LF requirement keeps the -# `#!/usr/bin/env pwsh` shebang at the top of every script in scripts/ -# working on Linux/macOS (CR breaks the kernel's exec lookup, and a -# leading BOM prevents shebang recognition entirely). +# PowerShell scripts inherit LF + UTF-8 (no BOM) + 4-space indent from +# the global [*] section above — no per-language override is needed. +# LF + no-BOM is required for the `#!/usr/bin/env pwsh` shebang (where +# present) on scripts under scripts/ to work on Linux/macOS — CR +# breaks the kernel's exec lookup, and a leading BOM prevents shebang +# recognition entirely. # C# files [*.cs] diff --git a/.github/workflows/build-all-versions.yaml b/.github/workflows/build-all-versions.yaml index 890b5ae2..b2b546cb 100644 --- a/.github/workflows/build-all-versions.yaml +++ b/.github/workflows/build-all-versions.yaml @@ -119,20 +119,20 @@ jobs: try { # Attempt dotnet restore + build; failures are non-fatal because # DocFX can still extract metadata from source files. - $slnFile = Get-ChildItem -Filter '*.sln' -ErrorAction SilentlyContinue | + $slnFile = Get-ChildItem -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.sln','.slnx' } | Select-Object -First 1 if ($slnFile) { Write-Host "Restoring $($slnFile.Name)..." - dotnet restore $slnFile.FullName 2>&1 | Write-Host + dotnet restore $slnFile.FullName 2>&1 | Out-Host Write-Host "Building $($slnFile.Name)..." - dotnet build $slnFile.FullName --configuration Release --no-restore 2>&1 | Write-Host + dotnet build $slnFile.FullName --configuration Release --no-restore 2>&1 | Out-Host } Write-Host "Running docfx metadata..." - docfx metadata docfx_project/docfx.json 2>&1 | Write-Host + docfx metadata docfx_project/docfx.json 2>&1 | Out-Host Write-Host "Running docfx build..." - docfx build docfx_project/docfx.json 2>&1 | Write-Host + docfx build docfx_project/docfx.json 2>&1 | Out-Host if (Test-Path 'docfx_project/_site') { $dest = Join-Path $outDir 'versions' $version @@ -207,7 +207,7 @@ jobs: Push-Location $latestWorkDir try { - $slnFile = Get-ChildItem -Filter '*.sln' -ErrorAction SilentlyContinue | + $slnFile = Get-ChildItem -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.sln','.slnx' } | Select-Object -First 1 if ($slnFile) { Write-Host "Restoring $($slnFile.Name)..." @@ -285,9 +285,22 @@ jobs: Sort-Object -Property Major, Minor, Patch, Stable -Descending | Select-Object -ExpandProperty Tag + # Only emit version-picker entries for tags whose docs were actually + # built and copied under $outDir/versions//. Skipped tags (worktree + # add failed, DocFX produced no _site, etc.) would otherwise appear in + # versions.json as links to 404s on gh-pages. + $versionsDir = Join-Path $outDir 'versions' + $builtTags = if (Test-Path $versionsDir) { + Get-ChildItem -Path $versionsDir -Directory | Select-Object -ExpandProperty Name + } else { @() } + [array]$versions = @([PSCustomObject]@{ version = 'latest'; url = "${base}versions/latest/" }) foreach ($t in $orderedTags) { - $versions += [PSCustomObject]@{ version = $t; url = "${base}versions/$t/" } + if ($builtTags -contains $t) { + $versions += [PSCustomObject]@{ version = $t; url = "${base}versions/$t/" } + } else { + Write-Host "::notice::Skipping versions.json entry for $t — no built docs under versions/$t/" + } } $versionsJson = ConvertTo-Json -InputObject $versions -Depth 3 diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index c3dfa777..b0a09fcd 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -80,6 +80,9 @@ jobs: uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} + # security-extended adds the broader security query pack on top of the + # default queries (more rules, slightly longer scans). + queries: security-extended - name: Setup .NET if: steps.check-csharp.outputs.has-csharp == 'true' @@ -87,6 +90,34 @@ jobs: with: dotnet-version: '10.0.x' + - name: Restore .NET workloads + if: steps.check-csharp.outputs.has-csharp == 'true' + shell: pwsh + run: | + # Skip entirely if no csproj declares a workload-bearing TFM (android/ios/ + # maccatalyst/maui/tvos/tizen/browser) — netX.Y-windows TFMs use + # SDK-bundled projection assemblies, not the workload installer, so + # they're intentionally excluded. Saves ~5-15s on pure-library + # repos and removes a network-dependent failure mode. + $hasWorkloadTfm = @(Get-ChildItem -Recurse -Filter *.csproj | + Select-String -Pattern 'net\d+\.\d+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' -List).Count -gt 0 + if (-not $hasWorkloadTfm) { + Write-Host "No workload-bearing TFMs — skipping dotnet workload restore" + exit 0 + } + $solution = Get-ChildItem -Path . -Recurse -Depth 2 -Include "*.sln", "*.slnx" | Select-Object -First 1 + if ($solution) { + Write-Host "Restoring workloads for $($solution.FullName)" + dotnet workload restore "$($solution.FullName)" + } else { + Write-Host "No solution found; restoring workloads for all projects" + dotnet workload restore + } + if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet workload restore failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + - name: Build for CodeQL Analysis id: build if: steps.check-csharp.outputs.has-csharp == 'true' diff --git a/.github/workflows/docfx.yaml b/.github/workflows/docfx.yaml index 02aecc6b..7ce4e271 100644 --- a/.github/workflows/docfx.yaml +++ b/.github/workflows/docfx.yaml @@ -101,6 +101,35 @@ jobs: Get-ChildItem "docfx_project/_site/api" shell: pwsh + - name: Generate code-coverage report into docs site (T1) + # Initiative T1 — publish coverage report to gh-pages alongside docs. + # Runs the test suite with Cobertura coverage collection, then uses + # ReportGenerator to render an HTML report into _site/coverage/. + # The published docs site gains a /coverage/ subpath. + # continue-on-error means a coverage failure (no tests, flaky tests, + # report generation issues) does not block the docs deploy. + if: hashFiles('tests/**/*.csproj') != '' + continue-on-error: true + shell: pwsh + run: | + # Coverage report only needs one TFM's worth of runs. The per-PR + # pr.yaml workflow already exercises every TFM across Stages 1/2/3, + # so re-running the full matrix during docs deploy multiplies job + # time and adds extra failure surface for older targets. Pin to + # net10.0 — the modern target that's always present in this fleet. + dotnet test --configuration Release --no-build --no-restore --framework net10.0 --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage-raw 2>&1 | Out-Host + dotnet tool update -g dotnet-reportgenerator-globaltool 2>$null || dotnet tool install -g dotnet-reportgenerator-globaltool 2>$null + $coverageFiles = @(Get-ChildItem -Path ./coverage-raw -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue) + if ($coverageFiles.Count -eq 0) { + Write-Host "::notice::No coverage files generated - skipping coverage report step" + exit 0 + } + $reports = ($coverageFiles | ForEach-Object { $_.FullName }) -join ';' + $outDir = "docfx_project/_site/coverage" + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + reportgenerator "-reports:$reports" "-targetdir:$outDir" "-reporttypes:Html;TextSummary" + Write-Host "Coverage report written to $outDir" + - name: Generate versions.json # Produces versions.json consumed by the DocFX version-switcher dropdown. # Site layout: @@ -170,14 +199,22 @@ jobs: $maxLen = [Math]::Max($aIds.Length, $bIds.Length) for ($i = 0; $i -lt $maxLen; $i++) { - if ($i -ge $aIds.Length) { return -1 } # a has fewer identifiers -> lower precedence - if ($i -ge $bIds.Length) { return 1 } # b has fewer identifiers -> lower precedence + # SemVer §11.4.4: fewer prerelease identifiers = lower precedence. + # In *descending* order (newest first), the lower-precedence + # value sorts AFTER the higher-precedence one, so the comparator + # must return positive when 'a' is the shorter (lower-precedence) + # one and negative when 'b' is. + if ($i -ge $aIds.Length) { return 1 } # a has fewer identifiers -> lower precedence -> sorts later + if ($i -ge $bIds.Length) { return -1 } # b has fewer identifiers -> lower precedence -> sorts later $aId = $aIds[$i] $bId = $bIds[$i] - $aIsNum = [int]::TryParse($aId, [ref]([int]$null)) - $bIsNum = [int]::TryParse($bId, [ref]([int]$null)) + # TryParse needs [ref] to a real variable, not an expression. + # We don't use the out value (we re-parse below via [int]$aId). + $aOut = 0; $bOut = 0 + $aIsNum = [int]::TryParse($aId, [ref]$aOut) + $bIsNum = [int]::TryParse($bId, [ref]$bOut) if ($aIsNum -and $bIsNum) { $aVal = [int]$aId @@ -220,65 +257,72 @@ jobs: Set-Content -Path 'docfx_project/_site/versions.json' -Encoding utf8NoBOM Write-Host "Generated versions.json with $($versions.Count) version(s): $($versions | ForEach-Object { $_.version })" - - name: Clean up stale root files from gh-pages - # Before deploying the latest docs to the site root, remove any pre-existing - # root-level files and folders from the gh-pages branch (except the versions/ - # directory, CNAME, and .nojekyll) so that stale DocFX assets from a previous - # build do not linger on the live site. - # The versions/ folder is preserved so that all versioned docs remain accessible - # while the root is refreshed with the new build. + - name: Verify previous versions preserved in versions.json + # Initiative D6 — guard against accidentally wiping the version selector. + # Fetches the currently-deployed versions.json from the published + # GitHub Pages URL (https://.github.io//versions.json) and confirms + # the newly-generated one has at least as many entries AND retains every + # previously-published version label. If anything shrunk or went missing, + # abort the deploy so the version selector cannot be wiped by accident. + # Only runs when an actual root-touching deploy is happening: + # - inputs.deploy_to_pages != false (otherwise it's a dry-run and + # nothing deploys at all) + # - inputs.deploy_as_latest != false (otherwise the deploy writes + # only versions// and never touches the root versions.json, + # so there's nothing for the preservation guard to protect — a + # transient Pages-fetch failure would block a legitimate rebuild) if: inputs.deploy_to_pages != false && inputs.deploy_as_latest != false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | - $branchExists = git ls-remote --heads origin gh-pages - if (-not $branchExists) { - Write-Host "ℹ️ gh-pages branch does not exist yet – skipping stale-file cleanup." - exit 0 + $newPath = 'docfx_project/_site/versions.json' + if (-Not (Test-Path $newPath)) { + # This step only runs when deploy_to_pages != false, so a real + # deploy is about to happen. Missing newly-generated versions.json + # means the docfx generate step is broken — we can't verify that + # we're preserving previously-published versions. Failing here is + # safer than letting the deploy proceed and wipe the root with + # whatever (possibly empty) state. + Write-Host "::error::Newly-generated docfx_project/_site/versions.json is missing — docfx generation is broken. Refusing to deploy without a verified version manifest." + exit 1 } - - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git remote set-url origin "https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$($env:GITHUB_REPOSITORY).git" - - git fetch origin gh-pages - # Create a local tracking branch only if it does not already exist - git show-ref --verify --quiet refs/heads/gh-pages - if ($LASTEXITCODE -ne 0) { - git branch gh-pages origin/gh-pages + $existingUrl = "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/versions.json" + try { + $existingRaw = (Invoke-WebRequest -Uri $existingUrl -ErrorAction Stop).Content + } catch { + # Only treat a true 404 as "first deploy". Other errors (network, + # DNS, Pages outage, auth/redirect) must NOT silently bypass the + # preservation check — they could let a deploy that drops version + # entries from the picker slip through. + $status = $null + if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } + if ($status -eq 404) { + Write-Host "::notice::No existing versions.json at $existingUrl (404) - first deploy, skipping preservation check." + exit 0 + } + Write-Error "Failed to fetch existing versions.json from $existingUrl (status=$status): $($_.Exception.Message). Aborting deploy to avoid masking a transient error." + exit 1 } - - $WORK_DIR = Join-Path $env:RUNNER_TEMP 'gh-pages-clean' - # Remove a leftover worktree from a previous failed run, if any - git worktree remove "$WORK_DIR" --force 2>&1 | Out-Null - if (Test-Path $WORK_DIR) { Remove-Item $WORK_DIR -Recurse -Force } - git worktree add "$WORK_DIR" gh-pages - - # Remove all root-level items EXCEPT: - # .git – Git metadata (worktree pointer file) - # CNAME – Custom domain config (if present) - # .nojekyll – Tells GitHub Pages not to run Jekyll - # versions/ – All versioned docs (v1.0.0/, latest/, etc.) - Get-ChildItem -Path $WORK_DIR -Force | Where-Object { - $_.Name -ne '.git' -and - $_.Name -ne 'CNAME' -and - $_.Name -ne '.nojekyll' -and - $_.Name -ne 'versions' - } | Remove-Item -Recurse -Force - - git -C "$WORK_DIR" add -A - git -C "$WORK_DIR" diff --cached --quiet - if ($LASTEXITCODE -ne 0) { - git -C "$WORK_DIR" commit ` - -m "chore: clean up stale root DocFX assets before redeploy [skip ci]" - git -C "$WORK_DIR" push origin HEAD:gh-pages - Write-Host "✅ Stale root files removed from gh-pages." - } else { - Write-Host "ℹ️ No stale files found in gh-pages root – nothing to clean." + try { + $existing = $existingRaw | ConvertFrom-Json + $new = Get-Content $newPath -Raw | ConvertFrom-Json + } catch { + Write-Error "Failed to parse versions.json: $($_.Exception.Message)" + exit 1 } - - git worktree remove "$WORK_DIR" --force + $existingCount = @($existing).Count + $newCount = @($new).Count + if ($newCount -lt $existingCount) { + Write-Error "versions.json would lose entries: existing=$existingCount, new=$newCount. Aborting deploy." + exit 1 + } + $existingVersions = @($existing) | ForEach-Object { $_.version } + $newVersions = @($new) | ForEach-Object { $_.version } + $missing = @($existingVersions | Where-Object { $_ -notin $newVersions }) + if ($missing.Count -gt 0) { + Write-Error "Previous versions missing from new versions.json: $($missing -join ', '). Aborting deploy." + exit 1 + } + Write-Host "versions.json preservation OK: existing=$existingCount, new=$newCount." - name: Compute destination directory # Determines the versioned subfolder name for the docs deployment (e.g. /v1.2.3/). @@ -355,17 +399,20 @@ jobs: # an existing gh-pages branch. $branchExists = git ls-remote --heads origin gh-pages if ($LASTEXITCODE -ne 0) { - Write-Error "git ls-remote --heads origin gh-pages failed with exit code $LASTEXITCODE — aborting before we accidentally bootstrap over an existing gh-pages branch." - exit 1 + # `throw` (not `exit 1`) so the outer try/finally cleanup runs and + # the global http.extraheader auth header is always unset. + throw "git ls-remote --heads origin gh-pages failed with exit code $LASTEXITCODE — aborting before we accidentally bootstrap over an existing gh-pages branch." } $useWorktree = [bool]$branchExists if ($useWorktree) { git fetch origin gh-pages + if ($LASTEXITCODE -ne 0) { throw "git fetch origin gh-pages failed with exit code $LASTEXITCODE" } git show-ref --verify --quiet refs/heads/gh-pages if ($LASTEXITCODE -ne 0) { git branch gh-pages origin/gh-pages } git worktree remove $WORK_DIR --force 2>&1 | Out-Null if (Test-Path -LiteralPath $WORK_DIR) { Remove-Item -LiteralPath $WORK_DIR -Recurse -Force } git worktree add $WORK_DIR gh-pages + if ($LASTEXITCODE -ne 0) { throw "git worktree add $WORK_DIR gh-pages failed with exit code $LASTEXITCODE" } } else { Write-Host "ℹ️ gh-pages does not exist yet — starting fresh." New-Item -ItemType Directory -Force -Path $WORK_DIR | Out-Null @@ -374,27 +421,51 @@ jobs: # Auth is provided by the global http.extraheader configured above, # so the remote URL does not embed the token. git -C $WORK_DIR init --initial-branch=gh-pages + if ($LASTEXITCODE -ne 0) { throw "git init in $WORK_DIR failed with exit code $LASTEXITCODE" } git -C $WORK_DIR remote add origin "https://github.com/$($env:GITHUB_REPOSITORY).git" + if ($LASTEXITCODE -ne 0) { throw "git remote add origin failed with exit code $LASTEXITCODE" } } - # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME - Get-ChildItem -Path $WORK_DIR -Force | Where-Object { - $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions') - } | Remove-Item -Recurse -Force + # Remove stale root files; preserve versions/, .git, .nojekyll, CNAME, dev. + # ('dev' is where benchmark-action/github-action-benchmark stores its + # chart + accumulated data.js — wiping it loses chart history on every release.) + # Gated on DEPLOY_AS_LATEST=true because the root is only repopulated + # (version-picker index.html, root versions.json, shared assets) in that + # branch below — a rebuild of an older version (deploy_as_latest=false) + # would otherwise strip the root and leave only /versions// paths. + if ($env:DEPLOY_AS_LATEST -eq 'true') { + Get-ChildItem -Path $WORK_DIR -Force | Where-Object { + $_.Name -notin @('.git', 'CNAME', '.nojekyll', 'versions', 'dev') + } | Remove-Item -Recurse -Force + } else { + Write-Host "Skipping root cleanup — DEPLOY_AS_LATEST is not 'true', preserving existing site root." + } # Ensure .nojekyll exists so GitHub Pages does not run Jekyll New-Item -ItemType File -Path (Join-Path $WORK_DIR '.nojekyll') -Force | Out-Null - # Deploy versioned docs (real DocFX index.html — before version picker overwrites it) + # Deploy versioned docs (real DocFX index.html — before version picker + # overwrites it). Clear the destination first so files that were + # dropped from docfx output between releases don't linger forever. $versionedDir = Join-Path $WORK_DIR "versions/$($env:VERSION_DIR)" - New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + if (Test-Path -LiteralPath $versionedDir) { + Get-ChildItem -LiteralPath $versionedDir -Force | Remove-Item -Recurse -Force + } else { + New-Item -ItemType Directory -Force -Path $versionedDir | Out-Null + } Copy-Item -Path "$siteDir/*" -Destination $versionedDir -Recurse -Force Write-Host "✅ Copied docs to versions/$($env:VERSION_DIR)/" if ($env:DEPLOY_AS_LATEST -eq 'true') { - # Deploy to versions/latest/ (real DocFX index.html) + # Deploy to versions/latest/ (real DocFX index.html). Same clear- + # before-copy pattern as versions/ above to prevent stale + # files from previous releases lingering when docfx output shrinks. $latestDir = Join-Path $WORK_DIR 'versions/latest' - New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + if (Test-Path -LiteralPath $latestDir) { + Get-ChildItem -LiteralPath $latestDir -Force | Remove-Item -Recurse -Force + } else { + New-Item -ItemType Directory -Force -Path $latestDir | Out-Null + } Copy-Item -Path "$siteDir/*" -Destination $latestDir -Recurse -Force Write-Host "✅ Copied docs to versions/latest/" @@ -413,8 +484,8 @@ jobs: $listHtml = $listItems -join "`n" if (-not (Test-Path '.github/version-picker-template.html')) { - Write-Error "Error: .github/version-picker-template.html not found; cannot generate root index.html." - exit 1 + # `throw` (not `exit 1`) so the outer try/finally cleanup runs. + throw ".github/version-picker-template.html not found; cannot generate root index.html." } $template = Get-Content '.github/version-picker-template.html' -Raw @@ -428,20 +499,37 @@ jobs: Write-Host "✅ Copied version picker to site root" } - # Single commit and push + # Single commit and push. + # `git diff --cached --quiet` exits 0 (no changes), 1 (changes + # exist), or >1 (error — e.g. cannot read index). Treat the + # three cases explicitly: commit only when there are changes + # (exit 1); on >1 abort the deploy with a clear error; on 0 + # report "nothing to deploy" and exit cleanly. git -C $WORK_DIR add -A git -C $WORK_DIR diff --cached --quiet - if ($LASTEXITCODE -ne 0) { + $diffExit = $LASTEXITCODE + if ($diffExit -eq 1) { $msg = if ($env:DEPLOY_AS_LATEST -eq 'true') { "docs: deploy $($env:VERSION_DIR) and update latest" } else { "docs: deploy $($env:VERSION_DIR)" } git -C $WORK_DIR commit -m $msg + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ git commit failed with exit code $LASTEXITCODE — aborting deploy." + throw "git commit failed" + } git -C $WORK_DIR push origin HEAD:gh-pages + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ git push origin HEAD:gh-pages failed with exit code $LASTEXITCODE — deploy did not land." + throw "git push failed" + } Write-Host "✅ Documentation deployed in a single commit." - } else { + } elseif ($diffExit -eq 0) { Write-Host "ℹ️ No documentation changes to deploy." + } else { + Write-Error "❌ git diff --cached --quiet exited with $diffExit (error reading the staged index) — aborting deploy." + throw "git diff failed" } # Capture the deploy outcome before the finally block runs cleanup diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6c4781f4..b0e53ac3 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -13,8 +13,6 @@ # for a maintainer to manually review and verify the changes before merging # - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands # (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it) -# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from -# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks # - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed name: PR Checks v3 (Gated) @@ -51,11 +49,24 @@ jobs: fetch-depth: 0 - name: Fetch trusted gitleaks config from main - # Prevent PR from modifying .gitleaks.toml to bypass the scan - run: | - git fetch origin main --depth=1 - git checkout origin/main -- .gitleaks.toml 2>/dev/null || true + # Prevent PR from modifying .gitleaks.toml to bypass the scan. + # Distinguish "file doesn't exist in main" (fine — gitleaks uses + # defaults) from "checkout failed for any other reason" (abort — + # silently using the PR version would defeat the guard). shell: bash + run: | + if ! git fetch origin main --depth=1; then + echo "::error::Failed to fetch origin/main — aborting before gitleaks scan." + exit 1 + fi + if git cat-file -e origin/main:.gitleaks.toml 2>/dev/null; then + if ! git checkout origin/main -- .gitleaks.toml; then + echo "::error::Failed to checkout origin/main:.gitleaks.toml — aborting to prevent silent fall-back to PR version." + exit 1 + fi + else + echo "::notice::.gitleaks.toml not present in origin/main — gitleaks will use defaults." + fi - name: Run gitleaks # gitleaks-action@v2 does not support pull_request_target, so invoke the CLI directly @@ -117,19 +128,36 @@ jobs: for config_file in "${config_files[@]}"; do # Handle glob patterns if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + # Find files matching the pattern in main branch. + # NOTE: use process substitution (`done < <(...)`) instead of a + # plain pipeline. A piped `while` runs in a subshell — an + # `exit 1` from inside would only kill the subshell, not the + # outer step, letting a failed copy silently fall back to the + # PR-supplied protected config. Process substitution runs the + # loop in the parent shell so exit actually terminates the job. + while read -r file; do if [ -n "$file" ]; then echo " ✓ Copying $file from main branch" mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$file" > "$file"; then + echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi fi - done + # Mask grep's exit 1 on zero matches with `|| true` — under + # `set -eo pipefail`, an empty match would otherwise fail the step, + # but a pattern like `*.ruleset` legitimately has no matches in + # repos that don't ship one. The empty stream is fine; the while + # loop simply doesn't iterate. + done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" + if ! git show "main-branch:$config_file" > "$config_file"; then + echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi else echo " ℹ️ $config_file not found in main branch, skipping" fi @@ -173,10 +201,13 @@ jobs: done # Check .globalconfig, .ruleset, and workflow files using the same git diff approach - # --diff-filter=AMRC: Added, Modified, Renamed, Copied (excludes Deleted) + # --diff-filter=AMRCD: Added, Modified, Renamed, Copied, Deleted. + # Including D so a PR that *deletes* a protected file (workflow, + # .globalconfig, .ruleset) also triggers the maintainer-review gate + # — a silent deletion is just as security-relevant as a silent edit. while IFS= read -r file; do changed_files+=("$file") - done < <(git diff --name-only --diff-filter=AMRC main-branch HEAD 2>/dev/null | grep -E '(\.(globalconfig|ruleset)|^\.github/workflows/.*\.ya?ml)$' || true) + done < <(git diff --name-only --diff-filter=AMRCD main-branch HEAD 2>/dev/null | grep -E '(\.(globalconfig|ruleset)|^\.github/workflows/.*\.ya?ml)$' || true) if [ ${#changed_files[@]} -gt 0 ]; then echo "" @@ -252,74 +283,42 @@ jobs: for config_file in "${config_files[@]}"; do # Handle glob patterns if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + # Find files matching the pattern in main branch. + # NOTE: use process substitution (`done < <(...)`) instead of a + # plain pipeline. A piped `while` runs in a subshell — an + # `exit 1` from inside would only kill the subshell, not the + # outer step, letting a failed copy silently fall back to the + # PR-supplied protected config. Process substitution runs the + # loop in the parent shell so exit actually terminates the job. + while read -r file; do if [ -n "$file" ]; then echo " ✓ Copying $file from main branch" mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$file" > "$file"; then + echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi fi - done + # Mask grep's exit 1 on zero matches with `|| true` — under + # `set -eo pipefail`, an empty match would otherwise fail the step, + # but a pattern like `*.ruleset` legitimately has no matches in + # repos that don't ship one. The empty stream is fine; the while + # loop simply doesn't iterate. + done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" - 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 - # Skip for Dependabot — its package-version bumps to protected files (e.g. - # Directory.Build.props) are legitimate and should not be overwritten by main's - # older versions. Dependabot's identity is GitHub-controlled and not spoofable. - if: github.event.pull_request.user.login != 'dependabot[bot]' - run: | - echo "Fetching configuration files from main branch to prevent malicious overrides..." - - # Fetch the main branch - git fetch origin main:main-branch - - # List of configuration files that should come from trusted main branch - config_files=( - ".editorconfig" - "Directory.Build.props" - "Directory.Build.targets" - "BannedSymbols.txt" - "*.globalconfig" - "*.ruleset" - ".github/workflows/*.yml" - ".github/workflows/*.yaml" - ) - - # Copy each configuration file from main branch if it exists - for config_file in "${config_files[@]}"; do - # Handle glob patterns - if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do - if [ -n "$file" ]; then - echo " ✓ Copying $file from main branch" - mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$config_file" > "$config_file"; then + echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 fi - 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" @@ -350,6 +349,20 @@ jobs: 9.0.x 10.0.x + - name: Restore .NET workloads + # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via + # their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them + # before restore; for pure-library repos with no workload TFMs, skip entirely to + # avoid ~5-15s of network-dependent setup and an extra failure mode. + shell: bash + run: | + if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then + echo "Workload-bearing TFMs detected — running dotnet workload restore" + dotnet workload restore + else + echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" + fi + - name: Restore and build (exclude .NET Framework-only projects) run: | echo "Finding .NET project files in repository (via find command)..." @@ -449,15 +462,27 @@ jobs: # Gracefully skip if there is no ./tests directory (e.g. template-publishing # repos or library repos in early development that have no tests yet). # The downstream coverage steps already handle the no-coverage-files case. + # Fail loudly if the repo HAS src/ projects — the coverage gate + # exists to enforce test coverage on shipping code, so silently + # passing when tests are missing is the wrong default. Skip only + # for template-pack / in-dev repos with no source projects yet. if [ ! -d ./tests ]; then - echo "ℹ️ No ./tests directory — skipping test stage." + if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then + echo "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate." + exit 1 + fi + echo "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)." exit 0 fi - mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then - echo "ℹ️ No test projects found under ./tests — skipping test stage." + if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then + echo "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate." + exit 1 + fi + echo "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)." exit 0 fi @@ -500,6 +525,7 @@ jobs: dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --no-build --no-restore \ --collect:"XPlat Code Coverage" \ --settings coverlet.runsettings \ --results-directory "./TestResults" \ @@ -545,15 +571,24 @@ jobs: failed_projects="" threshold=${CODECOV_MINIMUM:-90} - + matched_count=0 + while read -r line; do - # Match lines with module names and percentages - if echo "$line" | grep -qE '^[^ ].*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then + # Match lines with module names and percentages. The percent + # capture is the LAST %-suffixed number on the line, matching + # Stage 2's behavior — ReportGenerator Summary.txt rows often + # have line/branch/method columns and the overall figure is at + # end-of-line. + if echo "$line" | grep -qE '^[^ ].*[0-9]+(\.[0-9]+)?%$' && ! echo "$line" | grep -q '^Summary'; then module=$(echo "$line" | awk '{print $1}') - percent=$(echo "$line" | awk '{print $NF}' | tr -d '%') - + # Floor the percent to int (matches Stage 2 pwsh's [int][math]::Floor) + # so we can use bash's integer -lt comparator below without + # erroring on decimals like "90.4". + percent=$(echo "$line" | awk '{print $NF}' | tr -d '%' | awk '{print int($1)}') + matched_count=$((matched_count + 1)) + echo "Checking module: '$module' - Coverage: ${percent}%" - + if [ "$percent" -lt "$threshold" ]; then echo " ❌ FAIL: Below ${threshold}% threshold" failed_projects="$failed_projects $module (${percent}%)" @@ -563,6 +598,14 @@ jobs: fi done < CoverageReport/Summary.txt + # Fail loudly when 0 modules matched - the regex is wrong or + # Summary.txt format changed. Silently passing the gate when we + # couldn't parse coverage is worse than failing. + if [ "$matched_count" -eq 0 ]; then + echo "❌ Coverage parser matched 0 modules in Summary.txt - regex or report format is out of sync. Refusing to silently pass the gate." + exit 1 + fi + if [ -n "$failed_projects" ]; then echo "" echo "==========================================" @@ -615,14 +658,17 @@ jobs: persist-credentials: false - name: Fetch trusted configuration files from main branch - shell: pwsh + # Skip for Dependabot — its package-version bumps to protected files (e.g. + # Directory.Build.props) are legitimate and should not be overwritten by main's + # older versions. Dependabot's identity is GitHub-controlled and not spoofable. if: github.event.pull_request.user.login != 'dependabot[bot]' + shell: pwsh run: | Write-Host "Fetching configuration files from main branch to prevent malicious overrides..." - + # Fetch the main branch git fetch origin main:main-branch - + # List of configuration files that should come from trusted main branch $configFiles = @( ".editorconfig", @@ -630,19 +676,19 @@ jobs: "Directory.Build.targets", "BannedSymbols.txt" ) - + # Copy each configuration file from main branch if it exists foreach ($configFile in $configFiles) { # Check if file exists in main branch $exists = git cat-file -e "main-branch:$configFile" 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host " ✓ Copying $configFile from main branch" - git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8NoBOM -NoNewline + git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8NoBOM } else { Write-Host " ℹ️ $configFile not found in main branch, skipping" } } - + # Handle glob patterns for .globalconfig, .ruleset, and workflow files $globPatterns = @("*.globalconfig", "*.ruleset", ".github/workflows/*.yml", ".github/workflows/*.yaml") foreach ($pattern in $globPatterns) { @@ -652,11 +698,11 @@ jobs: Write-Host " ✓ Copying $file from main branch" $dir = Split-Path -Parent $file if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } - git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8NoBOM -NoNewline + git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8NoBOM } } } - + Write-Host "" Write-Host "✅ Configuration files secured - using versions from main branch" @@ -672,6 +718,20 @@ jobs: 9.0.x 10.0.x + - name: Restore .NET workloads + # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via + # their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them + # before restore; for pure-library repos with no workload TFMs, skip entirely to + # avoid ~5-15s of network-dependent setup and an extra failure mode. + shell: bash + run: | + if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then + echo "Workload-bearing TFMs detected — running dotnet workload restore" + dotnet workload restore + else + echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" + fi + - name: Restore dependencies run: dotnet restore @@ -685,15 +745,29 @@ jobs: # Gracefully skip if there is no ./tests directory (e.g. template-publishing # repos or library repos in early development that have no tests yet). + # The coverage gate exists to enforce test coverage on shipping + # code. If ./src has projects but ./tests doesn't, fail loudly + # instead of silently passing the gate. Skip only for template- + # pack / in-dev repos that have no source projects yet. + $srcHasProjects = @(Get-ChildItem -Path './src' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj' -ErrorAction SilentlyContinue).Count -gt 0 + if (-not (Test-Path -Path './tests' -PathType Container)) { - Write-Host "ℹ️ No ./tests directory — skipping test stage." + if ($srcHasProjects) { + Write-Error "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate." + exit 1 + } + Write-Host "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)." exit 0 } $testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj') if (@($testProjects).Count -eq 0) { - Write-Host "ℹ️ No test projects found under ./tests — skipping test stage." + if ($srcHasProjects) { + Write-Error "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate." + exit 1 + } + Write-Host "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)." exit 0 } @@ -742,6 +816,7 @@ jobs: dotnet test $testProj.FullName ` --configuration Release ` --framework $fw ` + --no-build --no-restore ` --collect:"XPlat Code Coverage" ` --settings coverlet.runsettings ` --results-directory "./TestResults" ` @@ -750,6 +825,7 @@ jobs: dotnet test $testProj.FullName ` --configuration Release ` --framework $fw ` + --no-build --no-restore ` --logger "console;verbosity=normal" } @@ -801,11 +877,34 @@ jobs: $threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 } $failedProjects = @() + $matchedCount = 0 foreach ($line in (Get-Content "CoverageReport/Summary.txt")) { - if ($line -match '^\s*(\S+)\s+(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^\s*Summary') { + # Only consider top-level assembly rows: non-space first char, + # then anything, then whitespace + the final percent at EOL. + # Matches Stage 1's `^[^ ].*[0-9]+(\.[0-9]+)?%$` filter (which + # uses awk $NF for the percent — robust to extra columns like + # line/branch/method that ReportGenerator can emit on the same + # row). + # + # Bug previously here: a `.*` between the module name and the + # trailing `(\d+)%` was greedy and could eat all but the last + # digit of the percent — turning "100" into "0" and failing + # the gate on actually-100%-covered modules. Two changes: + # - Anchor on `^(\S+)` so indented sub-class rows are skipped + # (their parent assembly row carries the same number, so + # nothing is lost — and Stage 1 ignores them too). + # - Require whitespace immediately before the final `\d+%` + # (`\s(\d+...)%\s*$`). This still allows intermediate + # columns between the module name and the final percent + # (the `.*` consumes them), but `.*` can't terminate + # mid-digit-run — the regex engine MUST place `\s` before + # the digits, which forces the last %-suffixed number on + # the line to be captured intact. + if ($line -match '^(\S+).*\s(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^Summary') { $module = $Matches[1] $percent = [int][math]::Floor([double]$Matches[2]) + $matchedCount++ Write-Host "Checking module: '$module' - Coverage: ${percent}%" @@ -818,6 +917,14 @@ jobs: } } + # Fail loudly when 0 modules matched — the regex is wrong or + # Summary.txt format changed. Silently passing the gate when we + # couldn't read coverage is worse than failing. + if ($matchedCount -eq 0) { + Write-Error "❌ Coverage parser matched 0 modules in Summary.txt — regex or report format is out of sync. Refusing to silently pass the gate." + exit 1 + } + if ($failedProjects.Count -gt 0) { Write-Host "" Write-Host "==========================================" -ForegroundColor Red @@ -888,74 +995,42 @@ jobs: for config_file in "${config_files[@]}"; do # Handle glob patterns if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + # Find files matching the pattern in main branch. + # NOTE: use process substitution (`done < <(...)`) instead of a + # plain pipeline. A piped `while` runs in a subshell — an + # `exit 1` from inside would only kill the subshell, not the + # outer step, letting a failed copy silently fall back to the + # PR-supplied protected config. Process substitution runs the + # loop in the parent shell so exit actually terminates the job. + while read -r file; do if [ -n "$file" ]; then echo " ✓ Copying $file from main branch" mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$file" > "$file"; then + echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi fi - done + # Mask grep's exit 1 on zero matches with `|| true` — under + # `set -eo pipefail`, an empty match would otherwise fail the step, + # but a pattern like `*.ruleset` legitimately has no matches in + # repos that don't ship one. The empty stream is fine; the while + # loop simply doesn't iterate. + done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" - 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 - # Skip for Dependabot — its package-version bumps to protected files (e.g. - # Directory.Build.props) are legitimate and should not be overwritten by main's - # older versions. Dependabot's identity is GitHub-controlled and not spoofable. - if: github.event.pull_request.user.login != 'dependabot[bot]' - run: | - echo "Fetching configuration files from main branch to prevent malicious overrides..." - - # Fetch the main branch - git fetch origin main:main-branch - - # List of configuration files that should come from trusted main branch - config_files=( - ".editorconfig" - "Directory.Build.props" - "Directory.Build.targets" - "BannedSymbols.txt" - "*.globalconfig" - "*.ruleset" - ".github/workflows/*.yml" - ".github/workflows/*.yaml" - ) - - # Copy each configuration file from main branch if it exists - for config_file in "${config_files[@]}"; do - # Handle glob patterns - if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do - if [ -n "$file" ]; then - echo " ✓ Copying $file from main branch" - mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$config_file" > "$config_file"; then + echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 fi - 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" @@ -969,6 +1044,20 @@ jobs: 9.0.x 10.0.x + - name: Restore .NET workloads + # Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via + # their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them + # before restore; for pure-library repos with no workload TFMs, skip entirely to + # avoid ~5-15s of network-dependent setup and an extra failure mode. + shell: bash + run: | + if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then + echo "Workload-bearing TFMs detected — running dotnet workload restore" + dotnet workload restore + else + echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" + fi + - name: Restore and build (exclude .NET Framework-only projects) run: | echo "Enumerating tracked .NET project files (git ls-files)..." @@ -1066,18 +1155,30 @@ jobs: # Find all test projects (C#, VB.NET, F#). # Gracefully skip if there is no ./tests directory (e.g. template-publishing # repos or library repos in early development that have no tests yet). + # Fail loudly if the repo HAS src/ projects — the coverage gate + # exists to enforce test coverage on shipping code, so silently + # passing when tests are missing is the wrong default. Skip only + # for template-pack / in-dev repos with no source projects yet. if [ ! -d ./tests ]; then - echo "ℹ️ No ./tests directory — skipping test stage." + if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then + echo "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate." + exit 1 + fi + echo "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)." exit 0 fi test_projects=() while IFS= read -r -d '' file; do test_projects+=("$file") - done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0) + done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -not -name "*.Tests.Integration.*" -print0) if [ ${#test_projects[@]} -eq 0 ]; then - echo "ℹ️ No test projects found under ./tests — skipping test stage." + if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then + echo "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate." + exit 1 + fi + echo "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)." exit 0 fi @@ -1121,6 +1222,7 @@ jobs: dotnet test "$test_proj" \ --configuration Release \ --framework "$fw" \ + --no-build --no-restore \ --collect:"XPlat Code Coverage" \ --settings coverlet.runsettings \ --results-directory "./TestResults" \ @@ -1145,8 +1247,17 @@ jobs: - name: Enforce 90% coverage threshold run: | + # If no cobertura files were produced (no tests, all test projects + # skipped, etc.), the preceding step explicitly skipped report + # generation. Mirror that here — gating only when coverage was + # actually collected — instead of failing with "Coverage report + # not generated!" on jobs that legitimately had nothing to cover. + if ! find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then + echo "ℹ️ No coverage files produced — skipping coverage gate (consistent with the prior 'skipping report generation' notice)." + exit 0 + fi if [ ! -f "CoverageReport/Summary.txt" ]; then - echo "❌ Coverage report not generated!" + echo "❌ Coverage files exist but Summary.txt is missing — ReportGenerator failed." exit 1 fi @@ -1271,74 +1382,42 @@ jobs: for config_file in "${config_files[@]}"; do # Handle glob patterns if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do + # Find files matching the pattern in main branch. + # NOTE: use process substitution (`done < <(...)`) instead of a + # plain pipeline. A piped `while` runs in a subshell — an + # `exit 1` from inside would only kill the subshell, not the + # outer step, letting a failed copy silently fall back to the + # PR-supplied protected config. Process substitution runs the + # loop in the parent shell so exit actually terminates the job. + while read -r file; do if [ -n "$file" ]; then echo " ✓ Copying $file from main branch" mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$file" > "$file"; then + echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi fi - done + # Mask grep's exit 1 on zero matches with `|| true` — under + # `set -eo pipefail`, an empty match would otherwise fail the step, + # but a pattern like `*.ruleset` legitimately has no matches in + # repos that don't ship one. The empty stream is fine; the while + # loop simply doesn't iterate. + done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) else # Check if file exists in main branch if git cat-file -e "main-branch:$config_file" 2>/dev/null; then echo " ✓ Copying $config_file from main branch" - git show "main-branch:$config_file" > "$config_file" - 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 - # Skip for Dependabot — its package-version bumps to protected files (e.g. - # Directory.Build.props) are legitimate and should not be overwritten by main's - # older versions. Dependabot's identity is GitHub-controlled and not spoofable. - if: github.event.pull_request.user.login != 'dependabot[bot]' - run: | - echo "Fetching configuration files from main branch to prevent malicious overrides..." - - # Fetch the main branch - git fetch origin main:main-branch - - # List of configuration files that should come from trusted main branch - config_files=( - ".editorconfig" - "Directory.Build.props" - "Directory.Build.targets" - "BannedSymbols.txt" - "*.globalconfig" - "*.ruleset" - ".github/workflows/*.yml" - ".github/workflows/*.yaml" - ) - - # Copy each configuration file from main branch if it exists - for config_file in "${config_files[@]}"; do - # Handle glob patterns - if [[ "$config_file" == *"*"* ]]; then - # Find files matching the pattern in main branch - git ls-tree -r --name-only main-branch | grep -E "${config_file//\*/.*}" | while read -r file; do - if [ -n "$file" ]; then - echo " ✓ Copying $file from main branch" - mkdir -p "$(dirname "$file")" - git show "main-branch:$file" > "$file" || echo " ⚠️ Failed to copy $file" + if ! git show "main-branch:$config_file" > "$config_file"; then + echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 fi - 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" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9a2ae637..c3d64fc7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -563,9 +563,106 @@ jobs: if-no-files-found: warn # Publish to NuGet (only if validation passed) + # Verify the documentation builds cleanly BEFORE publishing the release — + # initiative D8. Builds DocFX without deploying so a docs failure blocks + # publish-nuget rather than landing after the package is already live. + verify-docs-build: + name: Verify Documentation Builds + runs-on: windows-latest + # Gate on the prior validation jobs so we don't burn ~5-10 min of Windows + # runner time on a release that's already failing earlier in the pipeline. + needs: [validate-release, pack-and-validate] + if: github.repository != 'Chris-Wolfgang/repo-template' + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Detect docfx project + id: check + shell: pwsh + run: | + if (Test-Path "docfx_project/docfx.json") { + "found=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + Write-Host "::notice::No docfx_project/docfx.json - skipping docs verification." + "found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + + - name: Setup .NET + if: steps.check.outputs.found == 'true' + uses: actions/setup-dotnet@v5 + with: + # Install the same SDK set as validate-release so dotnet build can + # compile every TFM the solution targets (some test projects span + # netcoreapp3.1 → net10.0). Without these, the docs verify step + # fails on repos with broad multi-targeting. + dotnet-version: | + 3.1.x + 5.0.x + 6.0.x + 7.0.x + 8.0.x + 9.0.x + 10.0.x + + - name: Restore .NET workloads + # Same gated probe as pr.yaml — only run dotnet workload restore when + # at least one csproj declares a workload-bearing TFM. Required for + # repos with MAUI/Android/iOS targets (e.g. Hawsey); fast no-op + # otherwise. Without this, dotnet build below fails on workload-bearing + # projects because the SDK can't resolve the workload assemblies. + if: steps.check.outputs.found == 'true' + shell: bash + run: | + if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then + echo "Workload-bearing TFMs detected — running dotnet workload restore" + dotnet workload restore + else + echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore" + fi + + - name: Restore dependencies + if: steps.check.outputs.found == 'true' + run: dotnet restore + + - name: Build solution (Release) + if: steps.check.outputs.found == 'true' + run: dotnet build --no-restore --configuration Release + + - name: Install DocFX + if: steps.check.outputs.found == 'true' + shell: pwsh + run: dotnet tool update docfx --global || dotnet tool install docfx --global + + - name: Build DocFX metadata + if: steps.check.outputs.found == 'true' + run: docfx metadata + working-directory: docfx_project + + - name: Build documentation (no deploy) + if: steps.check.outputs.found == 'true' + run: docfx build + working-directory: docfx_project + + - name: Verify documentation output + if: steps.check.outputs.found == 'true' + shell: pwsh + run: | + if (-Not (Test-Path "docfx_project/_site")) { + Write-Error "docfx_project/_site not found after build" + exit 1 + } + if (-Not (Test-Path "docfx_project/_site/api")) { + Write-Error "docfx_project/_site/api not found - API metadata generation may have failed" + exit 1 + } + Write-Host "Documentation built successfully - release may proceed" + publish-nuget: name: Publish to NuGet.org - needs: pack-and-validate + needs: [pack-and-validate, verify-docs-build] if: needs.pack-and-validate.outputs.has-packages == 'true' runs-on: windows-latest steps: @@ -674,6 +771,7 @@ jobs: tag_name: ${{ github.event.release.tag_name }} files: | ./nuget-packages/*.nupkg + ./nuget-packages/*.snupkg ./nuget-packages/*.bom.json release-coverage.zip diff --git a/.github/workflows/stryker.yaml b/.github/workflows/stryker.yaml new file mode 100644 index 00000000..82f96d23 --- /dev/null +++ b/.github/workflows/stryker.yaml @@ -0,0 +1,107 @@ +# Stryker mutation testing +# +# Runs the Stryker.NET mutation tester against the repo's test projects to +# measure mutation score. Mutation runs are slow — triggered manually +# (workflow_dispatch) and on a weekly schedule, not on every PR. +# +# The workflow looks for a stryker-config.json at the repo root or under +# tests/**/. If none is present the run is a no-op (Stryker setup is a +# per-repo follow-up; this file is the canonical infrastructure). +name: Stryker (mutation testing) + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 0' # weekly Sunday 06:00 UTC + +permissions: + contents: read + +jobs: + stryker: + name: Run Stryker + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out repo + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Detect stryker-config.json + id: check + shell: bash + run: | + # Explicit existence checks rather than globbing — `nullglob` only + # drops words that look like patterns (contain *, ?, [). The bare + # literal `stryker-config.json` has no glob characters, so it would + # be preserved as a literal even when the file doesn't exist, and + # the workflow would mistakenly think a config was present. + shopt -s globstar nullglob + configs=() + [ -f stryker-config.json ] && configs+=(stryker-config.json) + for cfg in tests/**/stryker-config.json; do + [ -f "$cfg" ] && configs+=("$cfg") + done + if (( ${#configs[@]} )); then + printf 'found=true\n' >> "$GITHUB_OUTPUT" + # NOTE: previously also wrote a configs</ to enable mutation testing." + printf 'found=false\n' >> "$GITHUB_OUTPUT" + fi + + - name: Setup .NET + if: steps.check.outputs.found == 'true' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Install dotnet-stryker + if: steps.check.outputs.found == 'true' + run: dotnet tool update -g dotnet-stryker || dotnet tool install -g dotnet-stryker + + - name: Run Stryker + if: steps.check.outputs.found == 'true' + shell: bash + run: | + set -e + shopt -s globstar nullglob + # Run BOTH a root stryker-config.json (if present) AND any + # tests/**/stryker-config.json suites. The Detect step collects + # both shapes; running only one leaves per-test suites unscanned + # in repos that have both an umbrella and per-suite configs. + ran=0 + if [ -f stryker-config.json ]; then + echo "::group::Stryker with root stryker-config.json" + dotnet stryker --config-file stryker-config.json + echo "::endgroup::" + ran=1 + fi + for cfg in tests/**/stryker-config.json; do + dir=$(dirname "$cfg") + echo "::group::Stryker in $dir" + (cd "$dir" && dotnet stryker) + echo "::endgroup::" + ran=1 + done + if [ "$ran" -eq 0 ]; then + echo "::error::Detect step said stryker-config.json was present, but Run found neither root nor tests/**/stryker-config.json. Bailing." + exit 1 + fi + + - name: Upload Stryker report + if: always() && steps.check.outputs.found == 'true' + uses: actions/upload-artifact@v7 + with: + name: stryker-report-${{ github.run_id }} + path: | + **/StrykerOutput/** + if-no-files-found: ignore + retention-days: 30 diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 0b80aad5..f047625c 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -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 - diff --git a/Directory.Build.props b/Directory.Build.props index b20a30e1..c0579999 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,14 @@ latest + + enable true @@ -56,4 +64,49 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + Chris Wolfgang + Chris Wolfgang + Copyright (c) Chris Wolfgang + git + true + true + true + snupkg + true + + + + + + all + + + diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/examples/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - diff --git a/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj b/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj index 9a2feb28..d7459919 100644 --- a/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj +++ b/examples/Net4.8/Example1-BasicETL/Example1-BasicETL.csproj @@ -8,7 +8,6 @@ 8 true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj b/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj index 4316f4ff..273b2973 100644 --- a/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj +++ b/examples/Net4.8/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj @@ -8,7 +8,6 @@ 8 true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj b/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj index 232b5775..826b920c 100644 --- a/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj +++ b/examples/Net4.8/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj @@ -8,7 +8,6 @@ 8 true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj b/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj index 28450714..e34ec595 100644 --- a/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj +++ b/examples/Net4.8/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj b/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj index a0a34ffa..60140d49 100644 --- a/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj +++ b/examples/Net4.8/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj b/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj index 45b3f05e..9da90757 100644 --- a/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj +++ b/examples/Net4.8/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj b/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj index 53cb685b..50216572 100644 --- a/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj +++ b/examples/Net4.8/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj b/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj index 5fdc3fb2..18135746 100644 --- a/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj +++ b/examples/Net4.8/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj @@ -8,7 +8,6 @@ latest true true - enable true false $(NoWarn);CA2007 diff --git a/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj b/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj index 9a9aa817..780a4107 100644 --- a/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj +++ b/examples/Net8.0/Example1-BasicETL/Example1-BasicETL.csproj @@ -5,7 +5,6 @@ net8.0 Example1_BasicETL enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj b/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj index cc13f612..d3f1d69e 100644 --- a/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj +++ b/examples/Net8.0/Example2-WithCancellationToken/Example2-WithCancellationToken.csproj @@ -5,7 +5,6 @@ net8.0 Example2_WithCancellationToken enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj b/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj index 9ea861bc..886608c1 100644 --- a/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj +++ b/examples/Net8.0/Example3-WithGracefulCancellation/Example3-WithGracefulCancellation.csproj @@ -5,7 +5,6 @@ net8.0 Example3_WithGracefulCancellation enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj b/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj index 5e9a9366..7e1297bd 100644 --- a/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj +++ b/examples/Net8.0/Example4a-WithExtractorProgress/Example4a-WithExtractorProgress.csproj @@ -5,7 +5,6 @@ net8.0 Example4a_WithExtractorProgress enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj b/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj index f544645a..96156244 100644 --- a/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj +++ b/examples/Net8.0/Example4b-WithTransformerProgress/Example4b-WithTransformerProgress.csproj @@ -5,7 +5,6 @@ net8.0 Example4b_WithTransformerProgress enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj b/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj index 12b6b994..874fc7e9 100644 --- a/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj +++ b/examples/Net8.0/Example4c-WithLoaderProgress/Example4c-WithLoaderProgress.csproj @@ -5,7 +5,6 @@ net8.0 Example4c_WithLoaderProgress enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj b/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj index d1d651a2..d678396a 100644 --- a/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj +++ b/examples/Net8.0/Example5a-ExtractorWithProgressAndCancellation/Example5a-ExtractorWithProgressAndCancellation.csproj @@ -5,7 +5,6 @@ net8.0 Example5a_ExtractorWithProgressAndCancellation enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj b/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj index 889e0a50..a759717b 100644 --- a/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj +++ b/examples/Net8.0/Example6-ReducingDuplicateCode/Example6-ReducingDuplicateCode.csproj @@ -5,7 +5,6 @@ net8.0 Example6_ReducingDuplicateCode enable - enable CA2007 1.0.0 Copyright {copyright year} {author} diff --git a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj index 74f956b0..43bb897e 100644 --- a/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj +++ b/src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj @@ -2,13 +2,10 @@ net462;net472;net48;net481;netstandard2.0;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0 latest - enable 0.13.0 False $(AssemblyName) - Chris Wolfgang Contains interfaces and base classes used to build ETL applications - Copyright 2025 Chris Wolfgang https://github.com/Chris-Wolfgang/ETL-Abstractions README.md https://github.com/Chris-Wolfgang/ETL-Abstractions diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props deleted file mode 100644 index a53b408b..00000000 --- a/tests/Directory.Build.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - false - - diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj index b3e112c4..88c03cf7 100644 --- a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/Wolfgang.Etl.Abstractions.Tests.Unit.csproj @@ -4,7 +4,6 @@ 0.13.0 latest enable - enable false true Copyright 2025 Chris Wolfgang