From 116b01d366af51df5f157c250ac18ba823f8900c Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 17:04:44 +1000 Subject: [PATCH 1/2] feat: add GitHub Actions CI/CD workflows and local test runner - ci.yml: build & test matrix across net8.0, net9.0, net10.0 on push/PR - release.yml: build, test, pack, tag-guard, GitHub Release, NuGet push - System.Text.Json.Stream.CI.slnf: solution filter (library + tests only, excludes benchmarks) - ci-cd-test-run.ps1: local runner with lint, dry-run, and full act execution modes --- .github/workflows/ci.yml | 66 +++++++ .github/workflows/release.yml | 88 +++++++++ ci-cd-test-run.ps1 | 292 ++++++++++++++++++++++++++++ src/System.Text.Json.Stream.CI.slnf | 9 + 4 files changed, 455 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 ci-cd-test-run.ps1 create mode 100644 src/System.Text.Json.Stream.CI.slnf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f39539 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +"on": + push: + branches: + - develop + - "feature/**" + - "fix/**" + - "hotfix/**" + pull_request: + branches: + - develop + - master + +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 (${{ matrix.framework }}) + 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 + strategy: + fail-fast: false + matrix: + framework: ["net8.0", "net9.0", "net10.0"] + steps: + - uses: actions/checkout@v5 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + - name: Restore + run: dotnet restore src/System.Text.Json.Stream.CI.slnf + - name: Build + run: dotnet build src/System.Text.Json.Stream.CI.slnf --no-restore --configuration Release -p:GeneratePackageOnBuild=false + - name: Test (${{ matrix.framework }}) + run: | + dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ + --configuration Release --framework ${{ matrix.framework }} \ + --logger "trx;LogFileName=test-results-${{ matrix.framework }}.trx" + - name: Upload test results + uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: test-results-${{ matrix.framework }} + path: "**/*.trx" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f3ab308 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +"on": + push: + branches: + - master + +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: | + 8.0.x + 9.0.x + 10.0.x + - name: Restore + run: dotnet restore src/System.Text.Json.Stream.CI.slnf + - name: Build + run: dotnet build src/System.Text.Json.Stream.CI.slnf --no-restore --configuration Release -p:GeneratePackageOnBuild=false + - name: Test (net8.0) + run: | + dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ + --configuration Release --framework net8.0 + - name: Test (net9.0) + run: | + dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ + --configuration Release --framework net9.0 + - name: Test (net10.0) + run: | + dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ + --configuration Release --framework net10.0 + - name: Extract version from csproj + id: version + run: | + VERSION=$(grep -m1 '' src/System.Text.Json.Stream/System.Text.Json.Stream.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/System.Text.Json.Stream/System.Text.Json.Stream.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/Utf8JsonAsyncStreamReader.${{ steps.version.outputs.version }}.nupkg \ + ./artifacts/Utf8JsonAsyncStreamReader.${{ steps.version.outputs.version }}.snupkg + - name: Push Utf8JsonAsyncStreamReader to NuGet.org + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet nuget push \ + "./artifacts/Utf8JsonAsyncStreamReader.${{ steps.version.outputs.version }}.nupkg" \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + - name: Push Utf8JsonAsyncStreamReader symbols to NuGet.org + if: steps.tag_check.outputs.exists == 'false' + run: | + dotnet nuget push \ + "./artifacts/Utf8JsonAsyncStreamReader.${{ 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..9206582 --- /dev/null +++ b/ci-cd-test-run.ps1 @@ -0,0 +1,292 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Local CI/CD test runner for Utf8JsonAsyncStreamReader. + +.DESCRIPTION + Validates and executes GitHub Actions workflows locally using actionlint (static analysis) + and act (Docker-based execution). Mirrors exactly what runs on GitHub Actions, including + all three .NET SDK versions required for the net8.0, net9.0, and net10.0 multi-target builds. + +.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' +$CiSlnf = Join-Path $RepoRoot 'src' 'System.Text.Json.Stream.CI.slnf' + +# ── 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') + +if ($needsAct) { + if (-not (Test-Tool 'dotnet')) { + Add-Error "Tool 'dotnet' not found. Install: https://dotnet.microsoft.com/download" + } else { + Write-Pass "dotnet $(dotnet --version)" + } +} + +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" } +if (-not (Test-Path $CiSlnf)) { Add-Error "Missing CI solution filter: $CiSlnf" } + +$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" + } + + 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' + } +} + +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 +} + +Write-Pass 'All prerequisites satisfied' + +# ══════════════════════════════════════════════════════════════════════════════ +# 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 + try { + $actArgs = @('push', '--workflows', $wf.File, '-n') + $eventPath = $null + + # Release workflow is gated on push to master; provide an explicit push event payload. + 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. + $failed = @($out | Where-Object { + $_ -match '(FAIL|error)' -and + $_ -notmatch 'DRYRUN' -and + $_ -notmatch 'upload-artifact' -and + $_ -notmatch '\.cache\\act\\actions-upload-artifact' -and + $_ -notmatch 'The system cannot find the file specified' + }) + $knownArtifactCacheIssue = @($out | Where-Object { + $_ -match 'actions-upload-artifact' -or + $_ -match 'The system cannot find the file specified' + }) + + $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 + try { + $actArgs = @($wf.Event, '--workflows', $wf.File) + if ($Job) { $actArgs += @('-j', $Job) } + + # 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 { + 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 +} diff --git a/src/System.Text.Json.Stream.CI.slnf b/src/System.Text.Json.Stream.CI.slnf new file mode 100644 index 0000000..ced8657 --- /dev/null +++ b/src/System.Text.Json.Stream.CI.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "System.Text.Json.Stream.sln", + "projects": [ + "System.Text.Json.Stream\\System.Text.Json.Stream.csproj", + "System.Text.Json.Stream.Tests\\System.Text.Json.Stream.Tests.csproj" + ] + } +} From cda6a6dc7507f3be44637c35e334125217505d71 Mon Sep 17 00:00:00 2001 From: Graeme Grant Date: Sun, 19 Apr 2026 17:12:02 +1000 Subject: [PATCH 2/2] fix: address Copilot PR review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml: add --no-build --no-restore to dotnet test step - ci.yml: clarify continue-on-error intent with comment (env.ACT approach rejected — trips actionlint) - release.yml: add --no-build --no-restore to all three dotnet test steps - ci-cd-test-run.ps1: downgrade dotnet host check to warning (act installs .NET in runner) - ci-cd-test-run.ps1: add Test-Tool 'docker' check before docker info for clearer error on missing vs. stopped Docker --- .github/workflows/ci.yml | 4 ++++ .github/workflows/release.yml | 6 +++--- ci-cd-test-run.ps1 | 26 +++++++++++++++++--------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f39539..4309ed3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,11 +55,15 @@ jobs: - name: Test (${{ matrix.framework }}) run: | dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ + --no-build --no-restore \ --configuration Release --framework ${{ matrix.framework }} \ --logger "trx;LogFileName=test-results-${{ matrix.framework }}.trx" - name: Upload test results uses: actions/upload-artifact@v7 if: always() + # continue-on-error is intentionally true: when running locally via act, ACTIONS_RUNTIME_TOKEN + # is not available and upload-artifact always fails. Using env.ACT == 'true' trips actionlint + # (ACT is not a standard GitHub context variable), so we keep the unconditional true here. continue-on-error: true with: name: test-results-${{ matrix.framework }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3ab308..c669c91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,15 +29,15 @@ jobs: - name: Test (net8.0) run: | dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ - --configuration Release --framework net8.0 + --no-build --no-restore --configuration Release --framework net8.0 - name: Test (net9.0) run: | dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ - --configuration Release --framework net9.0 + --no-build --no-restore --configuration Release --framework net9.0 - name: Test (net10.0) run: | dotnet test src/System.Text.Json.Stream.Tests/System.Text.Json.Stream.Tests.csproj \ - --configuration Release --framework net10.0 + --no-build --no-restore --configuration Release --framework net10.0 - name: Extract version from csproj id: version run: | diff --git a/ci-cd-test-run.ps1 b/ci-cd-test-run.ps1 index 9206582..a36e6b3 100644 --- a/ci-cd-test-run.ps1 +++ b/ci-cd-test-run.ps1 @@ -79,10 +79,10 @@ $needsActionlint = $Mode -in @('lint', 'all') $needsAct = $Mode -in @('dry', 'ci', 'all') if ($needsAct) { - if (-not (Test-Tool 'dotnet')) { - Add-Error "Tool 'dotnet' not found. Install: https://dotnet.microsoft.com/download" - } else { + if (Test-Tool 'dotnet') { Write-Pass "dotnet $(dotnet --version)" + } else { + Write-Warn "Tool 'dotnet' not found on host. Continuing because act-backed workflows install .NET inside the runner via actions/setup-dotnet." } } @@ -112,12 +112,20 @@ if ($needsAct) { Add-Error "Tool 'act' not found. Install: $installHint" } - 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' + $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 (or install Docker Desktop: https://docs.docker.com/desktop/setup/install/mac-install/)' } + else { 'Install Docker Engine: https://docs.docker.com/engine/install/' } + Add-Error "Tool 'docker' not found. Install: $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' + } } }