Skip to content

CI tooling pack (FLT-16)

Standard analysis/security gates for every C# repo in the fleet. Piloted in usage-governor, rolled out from there. This file is the contract: apply it as written, and record deviations here rather than inventing per-repo variants.

What is in the pack

Gate Where Enforcing? Why that setting
Vulnerable package gate step in existing ci.yml, after Build YES — fails the build Deterministic, seconds to run, zero false positives observed. Caught Microsoft.OpenApi GHSA-v5pm-xwqc-g5wc on the pilot repo.
ReSharper InspectCode new inspect.yml error-severity only Warning/note volume is dominated by DTO-contract + public-API-surface noise (81 findings / 0 errors on fleet-mission-control). Gating on those trains the fleet to ignore a red X. Full SARIF uploaded every run.
Stryker.NET mutation new mutation.yml NO — report-only, weekly See "Why mutation is not a gate" below.
npm audit (--omit=dev --audit-level=high) step in ci.yml YES Repos with a package.json only. Dev-only advisories are excluded: npm's "fix" for them is routinely a major downgrade of the build toolchain.
ESLint ng lint step YES Angular repos only (WorkWingman, fleet-mission-control).

Why mutation is not a gate

Two independent reasons, both learned the hard way:

  1. This infra inflates the score. VsTest sockets collapse mid-run on the self-hosted pool, and Stryker counts a timeout as a killed mutant. A broad-scope score from current infra is therefore a plausible-looking lie. The WorkWingman council removed exactly such a gate on 2026-07-11 for this reason (llm-council/reports/, and the comment at the top of WorkWingman/.github/workflows/mutation.yml). A fleet-wide enforcing threshold built on the same infra repeats that mistake at 10x the blast radius.
  2. Runtime. Mutation runs take minutes to hours and would starve the small self-hosted pool that every other repo's CI is queued behind.

So mutation runs weekly + on demand, publishes a number, and that number exists to be driven up by real tests. Promote a repo to an enforcing break threshold only when its score is real and its runner is stable — per repo, never fleet-wide by default.

Baselines are low on first run (fleet-mission-control: 39.19%). That is the honest starting point, not a failure.

Applying the pack to a repo

The template is not copy-paste-safe: every file below contains at least one repo-specific value. Copying without patching them targets the wrong project or scans the wrong graph.

  1. Worktree + branch (wt/WT-xxxx/FLT-16) — never edit master directly.
  2. Copy .github/workflows/inspect.yml and .github/workflows/mutation.yml. Patch:
  3. the solution name in the InspectCode step,
  4. the cron slot (see the stagger table — do not reuse another repo's),
  5. the SDK version to match that repo's existing ci.yml / global.json; do not assume 10.0.x.
  6. Copy .config/dotnet-tools.json (pinned jb + dotnet-stryker).
  7. Add stryker-config.json at the repo root: project = the main library under test, test-projects = the test project(s), break: null.
  8. Add the "Vulnerable package gate" step to the existing ci.yml after Build, with that repo's solution named explicitly. Match the repo's existing shell — most fleet repos run steps through powershell -NoProfile -ExecutionPolicy Bypass; do not assume bash. Two repos (llm-council, claude-mem-house, worktree-identity, PavlokPager) do not use the pwsh-bypass default — check before pasting.
  9. Verify locally before landing: run the vuln gate, dotnet build, dotnet test, and one dotnet stryker baseline. Record the mutation score in the commit body.
  10. /council-code-review gate, then FF-push.

Mutation cron stagger (one slot per repo)

Hour-long jobs on a shared self-hosted pool must not start together.

Repo Cron (UTC)
usage-governor 0 6 * * 1
fleet-dispatch 0 8 * * 1
llm-council 0 10 * * 1
membus 0 12 * * 1
token-roi 0 14 * * 1
fleet-telegram 0 16 * * 1
worktree-identity 0 18 * * 1
claude-mem-house 0 20 * * 1
audio-loop-mcp 0 22 * * 1
PavlokPager existing (already had Stryker)
WorkWingman existing scoped gate — do not touch

Pinned actions (do not float)

actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8        # v5.0.0
actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9    # v4.3.1
actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2

Rollout status (2026-07-15)

Applied to 11 C# repos on branch wt/WT-df2b/FLT-16 (committed, not pushed). Every repo's build + tests + vuln gate were re-run by the orchestrator, not taken on the implementing agent's word (FLT-3):

Repo Tests verified Vuln rows Notes
usage-governor pilot 0 full pack; Stryker baseline 72.40% (3 timeouts)
fleet-dispatch 179/179 0
membus 101/101 0
token-roi 127/127 0
fleet-telegram 211/211 0
audio-loop-mcp 22/22 0
worktree-identity 207/207 0 stryker targets real tests/, not the stale nested worktree
claude-mem-house 31/31 0 no solution file → per-project scan
llm-council 69/69 0 existing stryker-config (85/70) preserved, not clobbered
PavlokPager 59/59 0 had Stryker already; floating -g install switched to pinned
WorkWingman build only 0 vuln gate + cron-only inspect; scoped mutation gate untouched

Known inconsistency to reconcile

claude-mem-house carries a stronger vuln gate than the rest: it also asserts a positive clean-scan marker (has no vulnerable packages) and throws "scan verdict unknown" if it sees neither rows nor the marker. That closes a residual hole the other 10 repos still have (an exit-0 scan that silently prints nothing reads as clean). Backport it to the pack and the other repos.

Frontend/JS: odysseus and nanoclaw-extras carry no real toolchain (no scripts / no devDependencies), so ESLint/audit gates would be theatre there — deliberately skipped. ESLint already exists in the only two Angular repos (WorkWingman, fleet-mission-control).

Gotchas

  • dotnet list package --vulnerable has BOTH failure modes, and they pull opposite ways. It exits 0 when it finds vulnerabilities (so the exit code cannot be the gate — parse the > rows), and it exits nonzero when it fails to run at all (restore error, bad solution, dead feed) — and that output has no > rows either. Parse-only therefore fails open: a scan that never ran reads exactly like a clean tree. Check $LASTEXITCODE first, then parse. Council caught this in the pilot before it was replicated 10x. Test the parser against a positive case too — a matcher that never fires is indistinguishable from a clean tree.
  • Never dotnet tool install -g on the self-hosted runners. They persist, so a global install makes results depend on leftover profile state and on whatever NuGet serves that day. Pin in .config/dotnet-tools.json + dotnet tool restore + dotnet tool run. A floating Stryker version silently moves the score, which is the whole artifact.
  • Microsoft.OpenApi 3.x breaks the Microsoft.AspNetCore.OpenApi 10.0.0 source generators (IOpenApiMediaType.Example is read-only). 2.7.5 is the first patched 2.x — take that, not "latest".
  • InspectCode's default profile flags public API surface as unused — DTOs, records, and contract types light it up. That is why the gate is error-only.
  • stryker-js does not bootstrap against Angular's builder-managed vitest (no standalone vitest config to discover): "No tests were executed". Frontend mutation is parked until that is solved; do not claim frontend mutation coverage that does not run.