fix: harden protocol and mutation boundaries #1671
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: ci | |
| # Trigger model (see CI_POLICY.md § Repository Evidence): | |
| # - pull_request — primary merge gate for every PR | |
| # - workflow_dispatch — manual re-run (keeps coverage + artifacts) | |
| # - schedule (weekly) — keeps coverage trend data and artifact freshness on main | |
| # | |
| # push-to-main was removed to eliminate the duplicate run that immediately follows | |
| # a squash-merge (the PR head commit was already validated seconds earlier). | |
| on: | |
| pull_request: | |
| workflow_dispatch: | |
| schedule: | |
| - cron: "45 5 * * 1" | |
| # Cancel superseded PR runs so a rapid push sequence doesn't leave zombie runs | |
| # burning a runner slot. Workflow-dispatch and scheduled runs never cancel. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| jobs: | |
| # Hosted-fallback router — see docs/self-hosted-runner.md | |
| # § Hosted fallback for an offline runner. | |
| # | |
| # runs-on is evaluated before any job executes, so routing around an | |
| # offline/wedged self-hosted runner requires a job that runs first and | |
| # feeds its decision through `needs.route.outputs`. This job always | |
| # completes (never skipped) so `validate`'s implicit `needs: route` | |
| # success() gate is never itself the reason validate gets skipped. | |
| # | |
| # Security boundary (must not change): fork PRs and dependabot PRs are | |
| # routed to ubuntu-latest unconditionally, without ever calling the | |
| # runners API — this job replicates the exact maintainer-PR predicate | |
| # from the original runs-on conditional and only probes runner status on | |
| # that branch. See docs/self-hosted-runner.md § Security model. | |
| route: | |
| name: Route runner | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 3 | |
| outputs: | |
| runs_on: ${{ steps.decide.outputs.runs_on }} | |
| steps: | |
| - name: Decide target runner | |
| id: decide | |
| shell: pwsh | |
| env: | |
| # Fine-grained PAT with Administration:read on this repo, stored | |
| # as the RUNNER_STATUS_PAT secret. The workflow GITHUB_TOKEN | |
| # cannot read the runners API (repo-admin scope). Operator setup | |
| # is documented in docs/self-hosted-runner.md § Availability; | |
| # until the secret is added this always falls back to the | |
| # current self-hosted routing (never blocks CI). | |
| GH_TOKEN: ${{ secrets.RUNNER_STATUS_PAT }} | |
| run: | | |
| $selfHostedJson = '["self-hosted","roslynmcp-dev"]' | |
| $hostedJson = '"ubuntu-latest"' | |
| $isMaintainerPr = "${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' }}" | |
| if ($isMaintainerPr -ne 'true') { | |
| # Fork PRs, dependabot PRs, workflow_dispatch, and schedule runs | |
| # never reach the self-hosted runner regardless of router | |
| # output or runner status — preserves the security boundary in | |
| # docs/self-hosted-runner.md § Security model. No API call is | |
| # made on this branch. | |
| Write-Host "Not a maintainer pull_request event - hosted routing (unconditional)." | |
| "runs_on=$hostedJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| exit 0 | |
| } | |
| if ([string]::IsNullOrWhiteSpace($env:GH_TOKEN)) { | |
| Write-Host "::notice title=Router::RUNNER_STATUS_PAT secret is not set - defaulting to self-hosted routing (current behavior)." | |
| "runs_on=$selfHostedJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| exit 0 | |
| } | |
| $apiErr = [System.IO.Path]::GetTempFileName() | |
| try { | |
| $apiJson = gh api "repos/${{ github.repository }}/actions/runners" 2>$apiErr | Out-String | |
| $apiStderr = Get-Content -Raw -LiteralPath $apiErr -ErrorAction SilentlyContinue | |
| if (-not [string]::IsNullOrWhiteSpace($apiStderr)) { | |
| Write-Host "gh api stderr (non-fatal unless the call also failed):`n$apiStderr" | |
| } | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "gh api exited $LASTEXITCODE" | |
| } | |
| $data = $apiJson | ConvertFrom-Json -ErrorAction Stop | |
| $online = @($data.runners) | Where-Object { | |
| $_.status -eq 'online' -and (@($_.labels | ForEach-Object { $_.name }) -contains 'roslynmcp-dev') | |
| } | |
| if ($online.Count -gt 0) { | |
| Write-Host "Self-hosted runner (label roslynmcp-dev) is online - routing to self-hosted." | |
| "runs_on=$selfHostedJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| } else { | |
| Write-Host "::notice title=Router::No online self-hosted runner with label roslynmcp-dev - routing to ubuntu-latest fallback." | |
| "runs_on=$hostedJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| } | |
| } catch { | |
| # Missing secret is handled above; this branch is an invalid | |
| # PAT, an API/schema failure, or a transient outage. Per | |
| # acceptance criterion 3, degrade to the known-working current | |
| # behavior (self-hosted) rather than guessing offline. | |
| Write-Host "::notice title=Router::Runner status probe failed ($($_.Exception.Message)) - defaulting to self-hosted routing (current behavior)." | |
| "runs_on=$selfHostedJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| # Without this, $LASTEXITCODE stays non-zero from the failed `gh api` | |
| # call above, and GitHub's pwsh step wrapper appends | |
| # `if ($LASTEXITCODE) { exit $LASTEXITCODE }` after this script - which | |
| # would fail this step (and therefore `route`) even though we degraded | |
| # gracefully. `finally` below still runs before this exit takes effect | |
| # (PowerShell guarantees finally runs even when exit unwinds a try/catch). | |
| exit 0 | |
| } finally { | |
| Remove-Item -LiteralPath $apiErr -Force -ErrorAction SilentlyContinue | |
| } | |
| validate: | |
| needs: route | |
| # Hybrid runner model — see docs/self-hosted-runner.md. | |
| # Maintainer pull_request events run on the self-hosted Windows runner | |
| # (faster wall-clock than hosted Linux) when it is online; the `route` | |
| # job above probes runner status and falls back to hosted Linux when it | |
| # is offline/wedged so PRs stop queueing indefinitely (see | |
| # docs/self-hosted-runner.md § Hosted fallback for an offline runner). | |
| # Fork PRs, dependabot PRs, workflow_dispatch, and scheduled runs always | |
| # stay on GitHub-hosted Linux regardless of router output: fork/dependabot | |
| # PRs execute third-party-selected code in tests, which must not run on | |
| # the maintainer's box, and routing dependabot to hosted also stops its | |
| # PR waves from queueing behind the single self-hosted slot. The `route` | |
| # job encodes this same predicate and never calls the runners API for | |
| # those events, so the security boundary is unchanged. | |
| runs-on: ${{ fromJSON(needs.route.outputs.runs_on) }} | |
| # Self-hosted validation currently completes in ~10-15 min, but hosted Linux | |
| # is materially slower: a July dependabot run needed 17m49s, and two fresh | |
| # August dependabot runs reached the former 20-min cap without reporting a | |
| # test failure. Keep 30 min of runner-class headroom while the per-command | |
| # timeouts below still fail a wedged dotnet child well before the job cap. | |
| timeout-minutes: 30 | |
| env: | |
| # Fail-fast command timeouts for the integration tests (TestBase binds these | |
| # ROSLYNMCP_*_TIMEOUT_SECONDS vars). Production defaults (5/10/5 min) are | |
| # sized for arbitrary user solutions; the pre-restored sample fixtures here | |
| # build in seconds, so a wedged child should fail its test in minutes, not | |
| # stack 5-minute timeouts into the job budget. | |
| ROSLYNMCP_BUILD_TIMEOUT_SECONDS: "150" | |
| ROSLYNMCP_TEST_TIMEOUT_SECONDS: "300" | |
| ROSLYNMCP_VULN_SCAN_TIMEOUT_SECONDS: "120" | |
| steps: | |
| - name: Check out repository | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Verify changelog contract | |
| shell: pwsh | |
| run: ./eng/verify-changelog-fragments.ps1 | |
| # Doc-only PRs (any **/*.md + ai_docs/**/*.json plan-state) don't change | |
| # compiled code, so skipping verify-release.ps1 and artifact uploads saves | |
| # ~12 minutes per reconcile-PR wave. The regex `^(.*\.md|ai_docs/.*\.json)$` | |
| # covers every .md file regardless of directory — including skills/**/*.md | |
| # (shipped plugin surface) and docs/**/*.md — plus ai_docs/*.json plan state. | |
| # verify-ai-docs.ps1 still runs unconditionally and now invokes | |
| # verify-skills-are-generic.ps1, so a skills/**/SKILL.md edit that introduces | |
| # a banned pattern is still caught even when verify-release.ps1 is skipped. | |
| # workflow_dispatch and schedule always run the full pipeline. See | |
| # CI_POLICY.md § Repository Evidence for the authoritative excluded-paths list. | |
| - name: Detect docs-only pull request | |
| id: detect | |
| if: github.event_name == 'pull_request' | |
| # Was shell: bash. Switched to pwsh because the self-hosted Windows | |
| # runner under LocalSystem can't invoke C:\Windows\System32\bash.EXE | |
| # (WSL refuses LocalSystem). pwsh is available on both hosted ubuntu | |
| # and self-hosted Windows; same script body works on both. | |
| shell: pwsh | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| $pr = "${{ github.event.pull_request.number }}" | |
| $changed = (gh pr diff $pr --name-only) -split "`n" | Where-Object { $_ -ne '' } | |
| Write-Host "Changed files:" | |
| $changed | ForEach-Object { Write-Host " $_" } | |
| if ($changed.Count -eq 0) { | |
| Write-Host "Empty diff - treating as non-docs-only." | |
| "docs_only=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| exit 0 | |
| } | |
| $nonDocs = $changed | Where-Object { $_ -notmatch '^(.*\.md|ai_docs/.*\.json)$' } | |
| if (-not $nonDocs) { | |
| "docs_only=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| Write-Host "::notice title=Docs-only PR::All changed files match **/*.md or ai_docs/**/*.json - skipping verify-release.ps1 and artifact uploads." | |
| } else { | |
| "docs_only=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 | |
| } | |
| - name: Set up .NET | |
| if: steps.detect.outputs.docs_only != 'true' | |
| uses: actions/setup-dotnet@v6 | |
| with: | |
| dotnet-version: 10.0.x | |
| # Cache the NuGet global-packages folder so repeated CI runs skip the ~5s | |
| # download of unchanged packages. The cache key keys off only the files | |
| # that actually drive the package graph (central version pin + root build | |
| # props + SDK version). Project-file edits no longer create a new cache | |
| # entry, which keeps the repo under the 10 GB Actions cache cap instead | |
| # of producing a fresh ~33 MB entry on every csproj tweak. | |
| # Hosted runners only: the self-hosted runner's ~/.nuget/packages persists | |
| # between runs, so cache save/restore there is pure transfer overhead. | |
| - name: Cache NuGet packages | |
| if: steps.detect.outputs.docs_only != 'true' && runner.environment == 'github-hosted' | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.nuget/packages | |
| key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/Directory.Build.props', '**/global.json') }} | |
| restore-keys: | | |
| ${{ runner.os }}-nuget- | |
| - name: Validate AI docs | |
| shell: pwsh | |
| run: ./eng/verify-ai-docs.ps1 | |
| # Coverage collection adds ~60-90s per run via coverlet IL-rewrite on every test | |
| # assembly, and CI_POLICY.md treats coverage as informational — not a merge gate. | |
| # Pass -NoCoverage on pull_request to skip the collection + HTML summary + upload; | |
| # workflow_dispatch and the weekly schedule keep full collection so the artifact | |
| # is still published and trend data continues. | |
| # -ExcludeNetworkTests: PR CI gates vulnerabilities via the dedicated audit | |
| # step below; the live api.nuget.org integration tests are redundant here and | |
| # network-flaky on the self-hosted runner. Dispatch/schedule stay unfiltered | |
| # so the live scan still runs weekly as a canary. | |
| - name: Verify release build (pull request — no coverage) | |
| if: steps.detect.outputs.docs_only != 'true' && github.event_name == 'pull_request' | |
| shell: pwsh | |
| run: ./eng/verify-release.ps1 -Configuration Release -NoCoverage -ExcludeNetworkTests | |
| - name: Verify release build (dispatch / schedule — with coverage) | |
| if: github.event_name != 'pull_request' | |
| shell: pwsh | |
| run: ./eng/verify-release.ps1 -Configuration Release | |
| - name: Generate coverage HTML summary | |
| if: github.event_name != 'pull_request' | |
| shell: pwsh | |
| run: | | |
| dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.4.7 --no-cache | |
| $tools = Join-Path $env:HOME ".dotnet/tools" | |
| $env:PATH = "$tools$([IO.Path]::PathSeparator)$env:PATH" | |
| $files = Get-ChildItem -Path "artifacts/coverage" -Recurse -Filter "coverage.cobertura.xml" -ErrorAction SilentlyContinue | |
| if (-not $files) { throw "No coverage.cobertura.xml under artifacts/coverage" } | |
| $reports = ($files | ForEach-Object { $_.FullName }) -join ";" | |
| $target = Join-Path $PWD "artifacts/coverage/report" | |
| reportgenerator "-reports:$reports" "-targetdir:$target" -reporttypes:HtmlSummary | |
| # `dotnet package list --vulnerable` exits 0 even when CVEs are present, so the | |
| # raw command can never fail the build — we must capture its output and gate on it. | |
| # Gate on the STRUCTURED `--format json` payload (a non-empty `vulnerabilities` | |
| # array on any package), NOT an English-language substring: keying on the | |
| # human-readable vulnerable-packages summary line silently reverts the gate to | |
| # fail-open the moment a dotnet SDK wording change or a non-English locale alters | |
| # that text. The `--include-transitive` path is parsed too, so a vulnerable | |
| # transitive dep also trips the gate. | |
| - name: Audit packages for known vulnerabilities | |
| if: steps.detect.outputs.docs_only != 'true' | |
| shell: pwsh | |
| run: | | |
| # Capture stdout (the JSON) and stderr SEPARATELY. Merging them (2>&1) into the | |
| # parsed string would let incidental dotnet/NuGet stderr — restore warnings, | |
| # first-run/telemetry notices — break ConvertFrom-Json on an otherwise-clean run | |
| # (the prior English-substring match tolerated such contamination; a JSON parse | |
| # does not). stderr is redirected to a temp file, echoed to the log for diagnostics, | |
| # and kept out of the payload the gate parses. Only `dotnet` (a native command) sets | |
| # $LASTEXITCODE; the Out-String/Get-Content/Remove-Item cmdlets below do not, so the | |
| # exit-code guard still reflects the audit command. | |
| $auditErr = [System.IO.Path]::GetTempFileName() | |
| $auditJson = dotnet package list --project RoslynMcp.slnx --vulnerable --include-transitive --format json 2>$auditErr | Out-String | |
| $auditStderr = Get-Content -Raw -LiteralPath $auditErr -ErrorAction SilentlyContinue | |
| Remove-Item -LiteralPath $auditErr -Force -ErrorAction SilentlyContinue | |
| Write-Host $auditJson | |
| if (-not [string]::IsNullOrWhiteSpace($auditStderr)) { | |
| Write-Host "dotnet package list stderr (non-fatal):`n$auditStderr" | |
| } | |
| # PowerShell does not honor $ErrorActionPreference for native commands, so a | |
| # genuine dotnet failure (unrestored project, missing .slnx, NuGet outage, SDK | |
| # fault) would otherwise fall through to the all-clear and fail the gate open. | |
| # Mirror eng/verify-release.ps1's Invoke-DotnetStep: fail the job on a non-zero | |
| # exit before the vuln check (a vuln finding itself still exits 0). | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Host "::error title=Vulnerability audit failed::dotnet package list exited $LASTEXITCODE (audit could not complete). See output above." | |
| exit 1 | |
| } | |
| # Unparseable / empty JSON means the audit did not produce a result we can trust, | |
| # so fail closed exactly like the non-zero-exit guard above rather than assuming | |
| # all-clear. | |
| try { | |
| $report = $auditJson | ConvertFrom-Json -ErrorAction Stop | |
| } catch { | |
| Write-Host "::error title=Vulnerability audit failed::Could not parse 'dotnet package list --format json' output as JSON ($($_.Exception.Message)). See output above." | |
| exit 1 | |
| } | |
| if ($null -eq $report -or $null -eq $report.projects) { | |
| Write-Host "::error title=Vulnerability audit failed::Audit JSON had no 'projects' array (unexpected schema). See output above." | |
| exit 1 | |
| } | |
| # Walk every project -> framework -> top-level AND transitive package and collect | |
| # any package carrying a non-empty `vulnerabilities` array. Missing keys are | |
| # treated as empty (a project with no findings emits `path` and no `frameworks`). | |
| $vulnerable = [System.Collections.Generic.List[string]]::new() | |
| foreach ($project in $report.projects) { | |
| foreach ($framework in @($project.frameworks)) { | |
| if ($null -eq $framework) { continue } | |
| $packages = @($framework.topLevelPackages) + @($framework.transitivePackages) | |
| foreach ($pkg in $packages) { | |
| if ($null -eq $pkg) { continue } | |
| if ($pkg.vulnerabilities -and @($pkg.vulnerabilities).Count -gt 0) { | |
| $sev = (@($pkg.vulnerabilities) | ForEach-Object { $_.severity }) -join ', ' | |
| $vulnerable.Add("$($pkg.id) $($pkg.resolvedVersion) [$sev]") | |
| } | |
| } | |
| } | |
| } | |
| if ($vulnerable.Count -gt 0) { | |
| Write-Host "::error title=Vulnerable packages detected::dotnet package list reported $($vulnerable.Count) package(s) with known vulnerabilities: $($vulnerable -join '; '). See the audit output above." | |
| exit 1 | |
| } | |
| Write-Host "No known package vulnerabilities found." | |
| - name: Upload published host artifact | |
| if: steps.detect.outputs.docs_only != 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: host-stdio-publish | |
| path: artifacts/publish/host-stdio | |
| retention-days: 14 | |
| - name: Upload release manifest | |
| if: steps.detect.outputs.docs_only != 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: release-manifests | |
| path: artifacts/manifests | |
| retention-days: 14 | |
| - name: Upload code coverage | |
| if: github.event_name != 'pull_request' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: code-coverage | |
| path: artifacts/coverage | |
| retention-days: 30 |