Skip to content

fix: harden protocol and mutation boundaries #1671

fix: harden protocol and mutation boundaries

fix: harden protocol and mutation boundaries #1671

Workflow file for this run

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