From ab1b31204d1ea6d744504648f730f8f367d18ca4 Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 21:32:03 +1000 Subject: [PATCH 1/5] feat: add GitHub Actions CI/CD workflows and local test runner - ci.yml: triggers on push to develop/feature/fix/hotfix branches and PRs to develop/master; validates branch naming on PRs; builds and tests on net10.0; uploads test results (.trx) as artifacts - release.yml: triggers on push to master; build, test, extract version, guard against re-releasing, pack, GitHub Release, push to NuGet.org - ci-cd-test-run.ps1: local runner with lint/dry/ci/all modes and ci/release/both workflow selection; uses RUNNING_LOCALLY env var gate to skip upload-artifact when running under act --- .github/workflows/ci.yml | 73 ++++++++ .github/workflows/release.yml | 95 ++++++++++ ci-cd-test-run.ps1 | 318 ++++++++++++++++++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 ci-cd-test-run.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd08c17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: + - develop + - "feature/**" + - "fix/**" + - "hotfix/**" + pull_request: + branches: + - develop + - master + +# Declared here so actionlint can validate env.RUNNING_LOCALLY references below. +# ci-cd-test-run.ps1 passes --env RUNNING_LOCALLY=true to act; on real GitHub Actions +# the variable is empty so upload-artifact runs and failures are fatal. +env: + RUNNING_LOCALLY: "" + +jobs: + validate-branch: + name: Validate Branch Name + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Check branch naming convention + env: + BRANCH: ${{ github.head_ref }} + run: | + if [[ ! "$BRANCH" =~ ^(feature|fix|hotfix)/.+ ]] && [[ "$BRANCH" != "develop" ]]; then + echo "::error::Branch '$BRANCH' does not follow naming conventions." + echo "::error::PR source branches must be 'develop' or prefixed with 'feature/', 'fix/', or 'hotfix/'." + exit 1 + fi + echo "Branch '$BRANCH' follows naming conventions." + + build-and-test: + name: Build & Test + needs: validate-branch + # Run on push events (validate-branch skipped) OR on valid PRs (validate-branch succeeded) + if: always() && (needs.validate-branch.result == 'success' || needs.validate-branch.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + + - name: Restore + run: dotnet restore Blazing.Json.Queryable.slnx + + - name: Build + run: dotnet build Blazing.Json.Queryable.slnx --no-restore --configuration Release -p:GeneratePackageOnBuild=false + + - name: Test + run: | + dotnet test tests/Blazing.Json.Queryable.Tests/Blazing.Json.Queryable.Tests.csproj \ + --no-build --no-restore \ + --configuration Release \ + --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + uses: actions/upload-artifact@v7 + # Skipped when running locally (ci-cd-test-run.ps1 passes --env RUNNING_LOCALLY=true to act). + # On real GitHub Actions RUNNING_LOCALLY is empty → step runs; upload failures are fatal. + if: always() && env.RUNNING_LOCALLY == '' + with: + name: test-results + path: "**/*.trx" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..646d3db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + branches: + - master + +# Declared here so actionlint can validate env.RUNNING_LOCALLY references below. +# ci-cd-test-run.ps1 passes --env RUNNING_LOCALLY=true to act; on real GitHub Actions +# the variable is empty so upload-artifact runs and failures are fatal. +env: + RUNNING_LOCALLY: "" + +jobs: + release: + name: Build, Test & Publish to NuGet + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + + - name: Restore + run: dotnet restore Blazing.Json.Queryable.slnx + + - name: Build + run: dotnet build Blazing.Json.Queryable.slnx --no-restore --configuration Release -p:GeneratePackageOnBuild=false + + - name: Test + run: | + dotnet test tests/Blazing.Json.Queryable.Tests/Blazing.Json.Queryable.Tests.csproj \ + --no-build --no-restore \ + --configuration Release + + - name: Extract version from project file + id: version + run: | + VERSION=$(grep -m1 '' src/Blazing.Json.Queryable/Blazing.Json.Queryable.csproj \ + | sed 's/.*//;s/<\/Version>.*//' \ + | tr -d '[:space:]') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + echo "Resolved version: $VERSION" + + - name: Check if this version was already released + id: tag_check + run: | + if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.version }}" | grep -q .; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Pack NuGet package + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet pack src/Blazing.Json.Queryable/Blazing.Json.Queryable.csproj \ + --no-build --configuration Release --output ./artifacts + + - name: Create GitHub Release + if: steps.tag_check.outputs.exists == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + --title "Release ${{ steps.version.outputs.tag }}" \ + --generate-notes \ + ./artifacts/Blazing.Json.Queryable.${{ steps.version.outputs.version }}.nupkg \ + ./artifacts/Blazing.Json.Queryable.${{ steps.version.outputs.version }}.snupkg + + - name: Push Blazing.Json.Queryable to NuGet.org + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet nuget push \ + "./artifacts/Blazing.Json.Queryable.${{ steps.version.outputs.version }}.nupkg" \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + + - name: Push Blazing.Json.Queryable symbols to NuGet.org + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet nuget push \ + "./artifacts/Blazing.Json.Queryable.${{ steps.version.outputs.version }}.snupkg" \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/ci-cd-test-run.ps1 b/ci-cd-test-run.ps1 new file mode 100644 index 0000000..2a3c931 --- /dev/null +++ b/ci-cd-test-run.ps1 @@ -0,0 +1,318 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Local CI/CD test runner for Blazing.Json.Queryable. + +.DESCRIPTION + Validates and executes GitHub Actions workflows locally using actionlint (static analysis) + and act (Docker-based execution). Mirrors exactly what runs on GitHub Actions. + +.PARAMETER Mode + dry - Validate workflow graph via act dry-run only (requires Docker + act) + lint - actionlint static analysis only + ci - Full workflow execution via act + all - lint + ci (default) + +.PARAMETER Workflow + ci - Run only ci.yml (default) + release - Run only release.yml + both - Run both workflows + +.PARAMETER Job + Optionally run one job by name (for Mode=ci). + +.EXAMPLE + .\ci-cd-test-run.ps1 + .\ci-cd-test-run.ps1 -Mode lint + .\ci-cd-test-run.ps1 -Mode dry + .\ci-cd-test-run.ps1 -Mode dry -Workflow both + .\ci-cd-test-run.ps1 -Mode ci -Workflow ci -Job build-and-test +#> + +[CmdletBinding()] +param( + [ValidateSet('dry', 'lint', 'ci', 'all')] + [string]$Mode = 'all', + + [ValidateSet('ci', 'release', 'both')] + [string]$Workflow = 'ci', + + [string]$Job = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ── Colours ──────────────────────────────────────────────────────────────────── +function Write-Header { param([string]$msg) Write-Host "`n━━━ $msg ━━━" -ForegroundColor Cyan } +function Write-Pass { param([string]$msg) Write-Host " ✅ $msg" -ForegroundColor Green } +function Write-Fail { param([string]$msg) Write-Host " ❌ $msg" -ForegroundColor Red } +function Write-Warn { param([string]$msg) Write-Host " ⚠ $msg" -ForegroundColor Yellow } +function Write-Section { param([string]$msg) Write-Host "`n ▶ $msg" -ForegroundColor White } + +$Script:Errors = [System.Collections.Generic.List[string]]::new() +$Script:Warnings = [System.Collections.Generic.List[string]]::new() + +function Add-Error { param([string]$msg) $Script:Errors.Add($msg); Write-Fail $msg } +function Add-Warning { param([string]$msg) $Script:Warnings.Add($msg); Write-Warn $msg } + +# ── Paths ────────────────────────────────────────────────────────────────────── +$RepoRoot = $PSScriptRoot +$WorkflowDir = Join-Path $RepoRoot '.github' 'workflows' +$CiYaml = Join-Path $WorkflowDir 'ci.yml' +$ReleaseYaml = Join-Path $WorkflowDir 'release.yml' + +# ── Tool check ───────────────────────────────────────────────────────────────── +function Test-Tool { + param([string]$Name) + return [bool](Get-Command $Name -ErrorAction SilentlyContinue) +} + +# ══════════════════════════════════════════════════════════════════════════════ +# STEP 1 — Prerequisite check +# ══════════════════════════════════════════════════════════════════════════════ +Write-Header 'Prerequisite Check' + +$needsActionlint = $Mode -in @('lint', 'all') +$needsAct = $Mode -in @('dry', 'ci', 'all') + +# dotnet: warn-only for act-backed modes — act installs .NET inside the runner +# via actions/setup-dotnet; the host machine does not need dotnet installed. +if ($needsAct) { + if (Test-Tool 'dotnet') { + Write-Pass "dotnet $(dotnet --version)" + } else { + Add-Warning "Tool 'dotnet' not found on host. Continuing because act-backed workflows install .NET inside the runner via actions/setup-dotnet." + } +} + +if (-not (Test-Path $CiYaml)) { Add-Error "Missing workflow file: $CiYaml" } +if (($Workflow -in @('release', 'both')) -and (-not (Test-Path $ReleaseYaml))) { Add-Error "Missing workflow file: $ReleaseYaml" } + +$hasActionlint = $false +if ($needsActionlint) { + $hasActionlint = Test-Tool 'actionlint' + if (-not $hasActionlint) { + $installHint = if ($IsWindows) { 'winget install rhysd.actionlint (or: choco install actionlint)' } + elseif ($IsMacOS) { 'brew install actionlint' } + else { 'go install github.com/rhysd/actionlint/cmd/actionlint@latest # or see https://github.com/rhysd/actionlint#installation' } + Add-Error "Tool 'actionlint' not found. Install: $installHint" + } +} + +$hasAct = $false +$dockerAvailable = $false +if ($needsAct) { + $hasAct = Test-Tool 'act' + if (-not $hasAct) { + $installHint = if ($IsWindows) { 'winget install nektos.act (or: choco install act-cli)' } + elseif ($IsMacOS) { 'brew install act' } + else { 'curl -s https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash # or see https://nektosact.com/installation/' } + Add-Error "Tool 'act' not found. Install: $installHint" + } + + # Check for the docker binary before calling docker info. + # If Docker is not installed, 'docker info' throws an unhelpful error. + $hasDocker = Test-Tool 'docker' + if (-not $hasDocker) { + $installHint = if ($IsWindows) { 'Install Docker Desktop: https://docs.docker.com/desktop/setup/install/windows-install/' } + elseif ($IsMacOS) { 'brew install --cask docker' } + else { 'Install Docker Engine: https://docs.docker.com/engine/install/' } + Add-Error "Tool 'docker' not found. $installHint" + } else { + try { + $null = docker info 2>$null + $dockerAvailable = $true + Write-Pass 'Docker daemon reachable' + } catch { + Add-Error 'Docker not reachable — act dry/ci modes require Docker daemon running' + } + } +} + +if ($Script:Errors.Count -gt 0) { + Write-Header 'Summary' + Write-Host "`n ❌ $($Script:Errors.Count) prerequisite error(s):" -ForegroundColor Red + $Script:Errors | ForEach-Object { Write-Host " • $_" -ForegroundColor Red } + Write-Host '' + exit 1 +} + +# ══════════════════════════════════════════════════════════════════════════════ +# STEP 2 — actionlint static analysis +# ══════════════════════════════════════════════════════════════════════════════ +if ($Mode -in @('lint', 'all') -and $hasActionlint) { + Write-Header 'YAML Static Analysis (actionlint)' + + $yamlFiles = @() + if ($Workflow -in @('ci', 'both')) { $yamlFiles += $CiYaml } + if ($Workflow -in @('release', 'both')) { $yamlFiles += $ReleaseYaml } + + foreach ($yaml in $yamlFiles) { + $name = Split-Path $yaml -Leaf + Write-Section "Linting $name" + $out = actionlint $yaml 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Pass "$name — no issues" + } else { + $out | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + Add-Error "$name has actionlint violations (see above)" + } + } +} + +# ══════════════════════════════════════════════════════════════════════════════ +# STEP 3 — act dry-run +# ══════════════════════════════════════════════════════════════════════════════ +if ($Mode -in @('dry', 'all') -and $dockerAvailable -and $hasAct) { + Write-Header 'Workflow Dry-Run (act -n)' + + $dryWorkflows = @() + if ($Workflow -in @('ci', 'both')) { $dryWorkflows += @{ Name = 'CI'; File = $CiYaml } } + if ($Workflow -in @('release', 'both')) { $dryWorkflows += @{ Name = 'Release'; File = $ReleaseYaml } } + + foreach ($wf in $dryWorkflows) { + Write-Section "Dry-run $($wf.Name) workflow" + Push-Location $RepoRoot + $eventPath = $null + try { + # Always pass --env RUNNING_LOCALLY=true so upload-artifact steps are skipped inside act. + $actArgs = @('push', '--workflows', $wf.File, '--env', 'RUNNING_LOCALLY=true', '-n') + + # Release workflow is gated on push to master; provide an explicit push event payload + # so act's default ref matches the branch filter and the workflow is not silently skipped. + if ($wf.Name -eq 'Release') { + $eventPath = [System.IO.Path]::GetTempFileName() + @{ + ref = 'refs/heads/master' + repository = @{ default_branch = 'master' } + head_commit = @{ id = 'local-dry-run' } + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $eventPath -Encoding UTF8 + $actArgs += @('-e', $eventPath) + } + + $out = & act @actArgs 2>&1 + # Filter known act Windows cache bug: upload-artifact may fail to remove its own + # .gitignore on Windows, causing a non-zero exit code even in dry-run mode. + # Use the specific cache path pattern — never a broad message — to avoid masking real errors. + $failed = @($out | Where-Object { + $_ -match '(FAIL|error)' -and + $_ -notmatch 'DRYRUN' -and + $_ -notmatch 'upload-artifact' -and + $_ -notmatch '\.cache\\act\\actions-upload-artifact' + }) + $knownArtifactCacheIssue = @($out | Where-Object { + $_ -match '\.cache\\act\\actions-upload-artifact' + }) + + # Detect when act ran but no dry-run jobs were staged (workflow likely skipped) + $dryRunLines = @($out | Where-Object { $_ -match '\*DRYRUN\* \[[^\]]+\]' }) + if ($dryRunLines.Count -eq 0) { + Add-Warning "$($wf.Name) dry-run: no jobs were staged — workflow may have been skipped. Verify trigger ref and branch filter." + } elseif ($LASTEXITCODE -eq 0 -and $failed.Count -eq 0) { + Write-Pass "$($wf.Name) dry-run succeeded" + } elseif ($LASTEXITCODE -ne 0 -and $failed.Count -eq 0 -and $knownArtifactCacheIssue.Count -gt 0) { + Add-Warning "$($wf.Name) dry-run hit known act artifact-cache cleanup issue; treating as success because no real failures were detected." + Write-Pass "$($wf.Name) dry-run succeeded" + } else { + $out | Where-Object { $_ -match '(FAIL|error|warn)' } | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + Add-Error "$($wf.Name) dry-run reported issues" + } + } finally { + if ($null -ne $eventPath -and (Test-Path -LiteralPath $eventPath)) { + Remove-Item -LiteralPath $eventPath -Force -ErrorAction SilentlyContinue + } + Pop-Location + } + } +} + +# ══════════════════════════════════════════════════════════════════════════════ +# STEP 4 — Full CI execution via act +# ══════════════════════════════════════════════════════════════════════════════ +if ($Mode -in @('ci', 'all') -and $dockerAvailable -and $hasAct) { + Write-Header 'Full CI Execution (act)' + + $actWorkflows = @() + if ($Workflow -in @('ci', 'both')) { $actWorkflows += @{ Name = 'CI'; File = $CiYaml; Event = 'push' } } + if ($Workflow -in @('release', 'both')) { $actWorkflows += @{ Name = 'Release'; File = $ReleaseYaml; Event = 'push' } } + + foreach ($wf in $actWorkflows) { + Write-Section "Running $($wf.Name) workflow via act" + Push-Location $RepoRoot + $eventPath = $null + try { + # Always pass --env RUNNING_LOCALLY=true so upload-artifact steps are skipped inside act. + $actArgs = @($wf.Event, '--workflows', $wf.File, '--env', 'RUNNING_LOCALLY=true') + if ($Job) { $actArgs += @('-j', $Job) } + + # Release workflow is gated on push to master; provide an explicit push event payload + # so act's default ref matches the branch filter and the workflow is not silently skipped. + if ($wf.Name -eq 'Release') { + $eventPath = [System.IO.Path]::GetTempFileName() + @{ + ref = 'refs/heads/master' + repository = @{ default_branch = 'master' } + head_commit = @{ id = 'local-ci-run' } + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $eventPath -Encoding UTF8 + $actArgs += @('-e', $eventPath) + } + + # Stream output, capture for analysis + $outLines = [System.Collections.Generic.List[string]]::new() + & act @actArgs 2>&1 | ForEach-Object { + $outLines.Add($_) + if ($_ -match '(✅|❌|🏁|PASS|FAIL|Error|error:|warning:)') { + Write-Host " $_" + } + } + + # Parse results — wrap in @() to force array type (.Count fails on plain strings) + $jobSucceeded = @($outLines | Where-Object { $_ -match '🏁.*Job succeeded' }) + $jobFailed = @($outLines | Where-Object { $_ -match '🏁.*Job failed' }) + $testPassed = @($outLines | Where-Object { $_ -match 'Passed!.*Failed:\s+0' }) + $testFailed = @($outLines | Where-Object { $_ -match 'Failed!.*Failed:\s+[^0]' }) + + if ($testPassed.Count -gt 0) { + $testPassed | ForEach-Object { Write-Pass ($_ -replace '^\|\s*', '') } + } + if ($testFailed.Count -gt 0) { + $testFailed | ForEach-Object { Add-Error ($_ -replace '^\|\s*', '') } + } + + # Ignore ACTIONS_RUNTIME_TOKEN artifact upload errors (known act limitation) + $realFailures = @($jobFailed | Where-Object { $_ -notmatch 'Upload test results' }) + + if ($LASTEXITCODE -eq 0 -or ($jobSucceeded.Count -gt 0 -and $realFailures.Count -eq 0)) { + Write-Pass "$($wf.Name) workflow — all jobs succeeded" + } else { + Add-Error "$($wf.Name) workflow had job failures (see above)" + } + } finally { + if ($null -ne $eventPath -and (Test-Path -LiteralPath $eventPath)) { + Remove-Item -LiteralPath $eventPath -Force -ErrorAction SilentlyContinue + } + Pop-Location + } + } +} + +# ══════════════════════════════════════════════════════════════════════════════ +# SUMMARY +# ══════════════════════════════════════════════════════════════════════════════ +Write-Header 'Summary' + +if ($Script:Warnings.Count -gt 0) { + Write-Host "`n Warnings:" -ForegroundColor Yellow + $Script:Warnings | ForEach-Object { Write-Host " ⚠ $_" -ForegroundColor Yellow } +} + +if ($Script:Errors.Count -eq 0) { + Write-Host "`n ✅ All checks passed!`n" -ForegroundColor Green + exit 0 +} else { + Write-Host "`n ❌ $($Script:Errors.Count) error(s) found:" -ForegroundColor Red + $Script:Errors | ForEach-Object { Write-Host " • $_" -ForegroundColor Red } + Write-Host '' + exit 1 +} From 812ccc975cca795ab4940086ca5431ee6a35fa1d Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 21:38:45 +1000 Subject: [PATCH 2/5] fix: address Copilot PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.yml: remove unused RUNNING_LOCALLY env block (no upload-artifact step in release workflow, so the variable was declared but never referenced) - release.yml: add --no-restore to dotnet pack (Restore step already ran; omitting it caused an implicit redundant restore) - ci-cd-test-run.ps1: update description wording — act uses push event so PR-only jobs (validate-branch) are not exercised locally; wording now accurately says 'approximates' rather than 'mirrors exactly' - ci-cd-test-run.ps1: guard -Job parameter to CI workflow only; if -Job is supplied with -Workflow release or both, emit a clear warning and skip the -j flag for the Release workflow to avoid act erroring on an unknown job name --- .github/workflows/release.yml | 8 +------- ci-cd-test-run.ps1 | 11 +++++++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 646d3db..c6acda8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,6 @@ on: branches: - master -# Declared here so actionlint can validate env.RUNNING_LOCALLY references below. -# ci-cd-test-run.ps1 passes --env RUNNING_LOCALLY=true to act; on real GitHub Actions -# the variable is empty so upload-artifact runs and failures are fatal. -env: - RUNNING_LOCALLY: "" - jobs: release: name: Build, Test & Publish to NuGet @@ -63,7 +57,7 @@ jobs: if: steps.tag_check.outputs.exists == 'false' run: | dotnet pack src/Blazing.Json.Queryable/Blazing.Json.Queryable.csproj \ - --no-build --configuration Release --output ./artifacts + --no-build --no-restore --configuration Release --output ./artifacts - name: Create GitHub Release if: steps.tag_check.outputs.exists == 'false' diff --git a/ci-cd-test-run.ps1 b/ci-cd-test-run.ps1 index 2a3c931..2bd268e 100644 --- a/ci-cd-test-run.ps1 +++ b/ci-cd-test-run.ps1 @@ -5,7 +5,8 @@ .DESCRIPTION Validates and executes GitHub Actions workflows locally using actionlint (static analysis) - and act (Docker-based execution). Mirrors exactly what runs on GitHub Actions. + and act (Docker-based execution). Approximates the GitHub Actions workflows that run in CI. + Note: act uses the push event — PR-only jobs (e.g. validate-branch) are not exercised locally. .PARAMETER Mode dry - Validate workflow graph via act dry-run only (requires Docker + act) @@ -244,7 +245,13 @@ if ($Mode -in @('ci', 'all') -and $dockerAvailable -and $hasAct) { try { # Always pass --env RUNNING_LOCALLY=true so upload-artifact steps are skipped inside act. $actArgs = @($wf.Event, '--workflows', $wf.File, '--env', 'RUNNING_LOCALLY=true') - if ($Job) { $actArgs += @('-j', $Job) } + # -Job only applies to the CI workflow; release.yml has a different job name. + # Passing an unknown job name to act causes it to error immediately. + if ($Job -and $wf.Name -eq 'CI') { + $actArgs += @('-j', $Job) + } elseif ($Job -and $wf.Name -ne 'CI') { + Add-Warning "-Job '$Job' ignored for $($wf.Name) workflow — job name may not exist in that workflow. Use -Workflow ci to target a specific job." + } # Release workflow is gated on push to master; provide an explicit push event payload # so act's default ref matches the branch filter and the workflow is not silently skipped. From 7ec079477d4a469a691d3008fb8f7c7786076811 Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 21:48:28 +1000 Subject: [PATCH 3/5] fix: prevent parallel test interference on SpanPropertyAccessor cache tests --- .../UnitTests/Evaluators/SpanPropertyAccessorTests.cs | 1 + .../UnitTests/Performance/PropertyAccessorCachingTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Blazing.Json.Queryable.Tests/UnitTests/Evaluators/SpanPropertyAccessorTests.cs b/tests/Blazing.Json.Queryable.Tests/UnitTests/Evaluators/SpanPropertyAccessorTests.cs index b21ffb3..b7189bd 100644 --- a/tests/Blazing.Json.Queryable.Tests/UnitTests/Evaluators/SpanPropertyAccessorTests.cs +++ b/tests/Blazing.Json.Queryable.Tests/UnitTests/Evaluators/SpanPropertyAccessorTests.cs @@ -9,6 +9,7 @@ namespace Blazing.Json.Queryable.Tests.UnitTests.Evaluators; /// /// Unit tests for SpanPropertyAccessor. /// +[Collection("SpanPropertyAccessorCache")] public class SpanPropertyAccessorTests { public SpanPropertyAccessorTests() diff --git a/tests/Blazing.Json.Queryable.Tests/UnitTests/Performance/PropertyAccessorCachingTests.cs b/tests/Blazing.Json.Queryable.Tests/UnitTests/Performance/PropertyAccessorCachingTests.cs index ac13875..cd7c8f3 100644 --- a/tests/Blazing.Json.Queryable.Tests/UnitTests/Performance/PropertyAccessorCachingTests.cs +++ b/tests/Blazing.Json.Queryable.Tests/UnitTests/Performance/PropertyAccessorCachingTests.cs @@ -9,6 +9,7 @@ namespace Blazing.Json.Queryable.Tests.UnitTests.Performance; /// Performance tests for property accessor caching behavior. /// Validates that property lookups are cached and don't allocate per-access. /// +[Collection("SpanPropertyAccessorCache")] public class PropertyAccessorCachingTests { public PropertyAccessorCachingTests() From 23170af6c58cea194adb330b7885c7526983c0ef Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 21:57:55 +1000 Subject: [PATCH 4/5] fix: disable parallelization on SpanPropertyAccessorCache collection to prevent static cache interference --- .../CollectionDefinitions.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs diff --git a/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs b/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs new file mode 100644 index 0000000..8e75ac7 --- /dev/null +++ b/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs @@ -0,0 +1,21 @@ +using Xunit; + +namespace Blazing.Json.Queryable.Tests; + +/// +/// Defines the SpanPropertyAccessorCache test collection. +/// +/// +/// +/// is set to so that when any +/// test in this collection executes, no other test collection runs concurrently. +/// +/// +/// This is required because +/// uses a process-wide static +/// cache. Tests in this collection call ClearCache() and assert on CacheCount — +/// assertions that are only valid when no other test is concurrently populating the cache. +/// +/// +[CollectionDefinition("SpanPropertyAccessorCache", DisableParallelization = true)] +public class SpanPropertyAccessorCacheCollection { } From 01a1c62e75efaed4ed72f48f56f3f8a9ff88ecd7 Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 22:15:18 +1000 Subject: [PATCH 5/5] fix: address Copilot PR #7 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.yml: add concurrency group to prevent race conditions when multiple pushes to master happen close together; cancel-in-progress is false so a running release is never aborted mid-way - release.yml: add explicit 'Fail if this version was already released' step after tag_check so a duplicate-version push to master fails loudly with ::error:: rather than silently succeeding with no publish - CollectionDefinitions.cs: fix XML doc cref targets — qualify DisableParallelization as Xunit.CollectionDefinitionAttribute.DisableParallelization and remove the space in ConcurrentDictionary{TKey,TValue} to avoid CS1574 warnings when doc generation is enabled --- .github/workflows/release.yml | 10 ++++++++++ .../CollectionDefinitions.cs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6acda8..f8c4a7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: branches: - master +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: release: name: Build, Test & Publish to NuGet @@ -53,6 +57,12 @@ jobs: echo "exists=false" >> $GITHUB_OUTPUT fi + - name: Fail if this version was already released + if: steps.tag_check.outputs.exists == 'true' + run: | + echo "::error::Release tag ${{ steps.version.outputs.tag }} already exists. Bump the project version before pushing to master." + exit 1 + - name: Pack NuGet package if: steps.tag_check.outputs.exists == 'false' run: | diff --git a/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs b/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs index 8e75ac7..acb51d5 100644 --- a/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs +++ b/tests/Blazing.Json.Queryable.Tests/CollectionDefinitions.cs @@ -7,12 +7,12 @@ namespace Blazing.Json.Queryable.Tests; /// /// /// -/// is set to so that when any +/// is set to so that when any /// test in this collection executes, no other test collection runs concurrently. /// /// /// This is required because -/// uses a process-wide static +/// uses a process-wide static /// cache. Tests in this collection call ClearCache() and assert on CacheCount — /// assertions that are only valid when no other test is concurrently populating the cache. ///