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:
- 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 ofWorkWingman/.github/workflows/mutation.yml). A fleet-wide enforcing threshold built on the same infra repeats that mistake at 10x the blast radius. - 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.
- Worktree + branch (
wt/WT-xxxx/FLT-16) — never edit master directly. - Copy
.github/workflows/inspect.ymland.github/workflows/mutation.yml. Patch: - the solution name in the InspectCode step,
- the cron slot (see the stagger table — do not reuse another repo's),
- the SDK version to match that repo's existing
ci.yml/global.json; do not assume10.0.x. - Copy
.config/dotnet-tools.json(pinnedjb+dotnet-stryker). - Add
stryker-config.jsonat the repo root:project= the main library under test,test-projects= the test project(s),break: null. - Add the "Vulnerable package gate" step to the existing
ci.ymlafter Build, with that repo's solution named explicitly. Match the repo's existing shell — most fleet repos run steps throughpowershell -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. - Verify locally before landing: run the vuln gate,
dotnet build,dotnet test, and onedotnet strykerbaseline. Record the mutation score in the commit body. /council-code-reviewgate, 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 --vulnerablehas 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$LASTEXITCODEfirst, 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 -gon 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.OpenApi3.x breaks theMicrosoft.AspNetCore.OpenApi10.0.0 source generators (IOpenApiMediaType.Exampleis 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.