Skip to content

Commit 6f1d933

Browse files
vitek-karasCopilot
andauthored
[aw] Make CI KBE issue search deterministic (#133684)
## Summary - Route CI failure scanner issue searches through a repository-scoped MCP script that returns only issue numbers and author logins. - Require every returned candidate to be inspected with `issue_read` before making semantic duplicate decisions. - Remove direct built-in issue-search access and strengthen the scanner eval to enforce the wrapper path and fail-closed behavior. ## Dependency This PR depends on [#133958](#133958), which must merge first. The `/ci-eval` workflow restores evaluator inputs from `main` before checking out the PR head; without the prerequisite files already present on `main`, the wrapper, grader, and focused tests are removed during restoration and this PR's evaluator changes are not exercised. ## Motivation The built-in issue search result projection can omit author metadata. When that happens, existing bot-authored KBEs can be hidden from duplicate detection even though prompt guidance requests the author field. This makes the transport deterministic while leaving non-exact query formulation and semantic comparison to the model. This is narrower than #132619 for issue lookup: pull request searches continue to use the GitHub MCP tools, while issue searches use the dedicated wrapper. ## Scope limitation The eval's direct-search protection is pattern-based and cannot recognize every possible shell spelling or equivalent command. This is an existing limitation of the eval setup and is out of scope for this PR; the workflow's network policy and mandatory wrapper checks remain the enforcement mechanisms for this change. ## Validation - Compiled `ci-failure-scan` with gh-aw v0.86.2 and actionlint enabled. - Passed Vally 0.14 strict lint for `ci-failure-scan.eval.yaml`. - Passed actionlint v1.7.12 for `ci-eval.yml`. - Exercised the wrapper against live KBE searches and confirmed bot-authored candidates include their author logins. - Passed the focused Node test suite with 10 tests. > [!NOTE] > This pull request description was generated by GitHub Copilot. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e0bb657 commit 6f1d933

9 files changed

Lines changed: 597 additions & 118 deletions

‎.github/workflows/ci-eval.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ jobs:
303303
scan_spec=.github/workflows/evals/ci-failure-scan.eval.yaml
304304
if [[ " $SPECS " == *" $scan_spec "* ]] &&
305305
test -f "$RUNNER_TEMP/trusted-evals/kbe-search.test.mjs"; then
306+
cp -- .github/workflows/ci-failure-scan.md \
307+
"$RUNNER_TEMP/ci-failure-scan.md"
306308
node --test "$RUNNER_TEMP/trusted-evals/kbe-search.test.mjs"
307309
fi
308310

‎.github/workflows/ci-failure-scan.lock.yml‎

Lines changed: 218 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.github/workflows/ci-failure-scan.md‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,79 @@ concurrency:
4141

4242
tools:
4343
github:
44-
toolsets: [pull_requests, repos, issues, search]
44+
type: remote
45+
github-token: ${{ secrets.GITHUB_TOKEN }}
46+
toolsets: [pull_requests, issues]
47+
allowed: [issue_read, search_pull_requests, pull_request_read]
48+
allowed-repos: [dotnet/runtime]
4549
min-integrity: approved
4650
edit:
4751
bash: ["dotnet", "git", "find", "ls", "cat", "grep", "head", "tail", "wc", "curl", "jq", "tee", "sed", "awk", "tr", "cut", "sort", "uniq", "xargs", "echo", "date", "mkdir", "test", "env", "basename", "dirname", "bash", "sh", "chmod"]
4852

53+
mcp-scripts:
54+
search-kbe-issues:
55+
description: "Search only dotnet/runtime issues and return inert candidate identifiers with author metadata. Use issue_read to inspect every candidate."
56+
inputs:
57+
query:
58+
type: string
59+
required: true
60+
description: "GitHub issue search query without a repo qualifier. The tool adds repo:dotnet/runtime and is:issue."
61+
script: |
62+
if (typeof query !== "string" || !query.trim()) {
63+
throw new Error("query must be a non-empty string");
64+
}
65+
const token = process.env.GITHUB_TOKEN;
66+
if (!token) {
67+
throw new Error("GITHUB_TOKEN must be set");
68+
}
69+
const { execFile } = await import("node:child_process");
70+
const { promisify } = await import("node:util");
71+
const { stdout } = await promisify(execFile)("gh", [
72+
"api",
73+
"search/issues",
74+
"--method",
75+
"GET",
76+
"--raw-field",
77+
`q=${query.trim()} repo:dotnet/runtime is:issue`,
78+
"--field",
79+
"per_page=10"
80+
], {
81+
env: { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token },
82+
maxBuffer: 1024 * 1024
83+
});
84+
let result;
85+
try {
86+
result = JSON.parse(stdout);
87+
} catch {
88+
throw new Error("gh issue search returned invalid JSON");
89+
}
90+
if (result.incomplete_results !== false ||
91+
!Array.isArray(result.items) ||
92+
!Number.isInteger(result.total_count)) {
93+
throw new Error("GitHub issue search returned an invalid response");
94+
}
95+
if (result.total_count > result.items.length) {
96+
throw new Error("GitHub issue search returned more matches than the page limit; narrow the query");
97+
}
98+
if (result.total_count !== result.items.length) {
99+
throw new Error("GitHub issue search returned an invalid response");
100+
}
101+
return result.items.map((item) => {
102+
if (item.repository_url !== "https://api.github.com/repos/dotnet/runtime" ||
103+
item.pull_request !== undefined ||
104+
!Number.isInteger(item.number) ||
105+
typeof item.user?.login !== "string" ||
106+
item.user.login.length === 0) {
107+
throw new Error("GitHub issue search returned an invalid candidate");
108+
}
109+
return {
110+
number: item.number,
111+
user: { login: item.user.login }
112+
};
113+
});
114+
env:
115+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
116+
49117
checkout:
50118
fetch-depth: 50
51119

@@ -64,6 +132,9 @@ network:
64132
- dev.azure.com
65133
- helix.dot.net
66134
- "*.blob.core.windows.net"
135+
blocked:
136+
- github
137+
- api.github.com
67138
---
68139

69140
# CI Outer-Loop Failure Scanner
@@ -90,6 +161,7 @@ The agent runs read-only. All writes go through `safe-outputs`.
90161
10. **All intermediate state under `/tmp/gh-aw/agent/`.** Each bash invocation is a fresh subshell; persist anything you want to keep.
91162
11. **AzDO API: anonymous only.** Stay on `_apis/build/...`. Never call `_apis/test/...` or `vstmr.dev.azure.com` (both redirect to sign-in).
92163
12. **Don't add `area-*` references to issue titles.** Multi-area titles produce multi-label assignments from the labeler bot.
164+
13. **Issue search transport is fixed.** Use only `search-kbe-issues` for GitHub issue searches, then inspect every returned candidate with `github` `issue_read` method `get`. Never use `github` `search_issues` or shell `gh` issue search.
93165

94166
## What this run must accomplish
95167

@@ -292,6 +364,16 @@ Follow exactly these sections from `.github/workflows/shared/create-kbe.instruct
292364
4. `<a id="search-existing-prs"></a>` / `## Search for existing PRs already handling the failure`
293365
5. `<a id="verify-embedded-issues"></a>` / `## Verify every embedded issue number exists`
294366

367+
All issue-search query variants in those sections MUST go through
368+
`search-kbe-issues`. The wrapper adds `repo:dotnet/runtime is:issue` and returns
369+
at most 10 inert candidate records containing only the issue number and
370+
`user.login`. For every returned number, call `github` `issue_read` with
371+
`owner: dotnet`, `repo: runtime`, and `method: get` before making any semantic
372+
duplicate or tracker decision. A wrapper failure or integrity-filtered/failed
373+
candidate read must fail closed as specified in the shared instructions.
374+
Use the built-in `search_pull_requests` and `pull_request_read` tools only for
375+
the PR searches in that flow.
376+
295377
When searching, account for the fact that the same signature can be filed in
296378
different `ErrorMessage` representations. A KBE recorded in `<a id="kbe-array-form"></a>`
297379
multi-line array form will not surface from a single-substring search, and vice

‎.github/workflows/evals/README.md‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,33 @@ for conformance, behavior, and constructiveness. Every grader must pass.
2626
The workflow preserves the eval specs and installs Vally from the trusted base
2727
branch before it checks out the PR head. This lets it evaluate PR changes to the
2828
workflow prompts without allowing the PR to weaken its graders or toolchain.
29-
The trusted KBE search helper, candidate grader, and focused tests are likewise
30-
kept in the runner's trusted temporary directory. When a scan eval uses the
31-
helper, `KBE_SEARCH_HELPER` identifies that copy; none of these files are
32-
restored into the agent-writable workspace.
33-
Each eval attaches a read-only GitHub MCP server with the `pull_requests`,
34-
`repos`, `issues`, and `search` toolsets. The `GITHUB_TOKEN` that the eval job
35-
supplies to that server has only the job's read permissions, allowing the
36-
scanner to use the `github` MCP server's `search_issues` tool.
29+
Each eval attaches a read-only GitHub MCP server with the toolsets its scenario
30+
needs. The `GITHUB_TOKEN` that the eval job supplies to that server has only the
31+
job's read permissions. The scanner eval omits the built-in `search` toolset and
32+
invokes a CLI harness for the workflow's `search-kbe-issues` MCP-script tool
33+
through Node because the eval runner does not launch workflow frontmatter MCP
34+
servers. It uses the `github` MCP server's `issue_read` tool for candidate
35+
inspection. A trusted static grader correlates every candidate returned by the
36+
harness with a successful, unfiltered `issue_read`. Focused Node tests keep the
37+
workflow-frontmatter and CLI wrapper behavior in sync and exercise the grader's
38+
candidate correlation.
3739

3840
These are format and behavior gates, not full ground-truth measurements. The
3941
second stage, a collector that scrapes the real failures and KBEs that actually
4042
exist and scores workflow output against them, is deferred.
4143

4244
- **`ci-failure-scan`** has the agent query the anonymous dnceng-public AzDO REST
4345
API for a currently-failing outer-loop build on `main`, extract a real error
44-
signature, check for an existing KBE, and emit the create-issue safe-output at
45-
`out/kbe.md`. Graders check the static Known Build Error format, meaning the
46-
title, exactly `Known Build Error` plus one blocking label, the three sections,
47-
collapsed authoring guidance, a single json signature, the collapsed
48-
workflow-owned positive match-count metadata, and no test-muting. They also check
49-
`tool-calls` evidence that it actually fetched a real build and searched existing
50-
KBEs.
46+
signature, check for an existing KBE, and either emit the create-issue
47+
safe-output at `out/kbe.md` or write the exact no-op result
48+
`Result: No new Known Build Error` when the live scan has nothing actionable
49+
to file. Graders check the static Known Build Error format when a KBE is
50+
emitted: the title, exactly `Known Build Error` plus one blocking label, the
51+
three sections, collapsed authoring guidance, a single json signature, the
52+
collapsed workflow-owned positive match-count metadata, and no test-muting.
53+
They also check `tool-calls` evidence that it actually fetched a real build,
54+
searched existing KBEs through the wrapper, and inspected returned candidates
55+
through `issue_read`.
5156

5257
- **`ci-failure-fix`** runs the workflow's deterministic scanner-author filter
5358
in trusted eval setup before the agent starts. The agent then reads a
@@ -104,7 +109,10 @@ export PATH="$PWD/.github/workflows/evals/node_modules/.bin:$PATH"
104109
export COPILOT_GITHUB_TOKEN="$(gh auth token)"
105110
export GH_TOKEN="$(gh auth token)"
106111
export GITHUB_PERSONAL_ACCESS_TOKEN="$(gh auth token)"
107-
vally lint --eval-spec .github/workflows/evals/ci-failure-scan.eval.yaml --strict
112+
export KBE_SEARCH_HELPER="$PWD/.github/workflows/evals/search-kbe-issues.cjs"
113+
vally lint --eval-spec .github/workflows/evals/ci-failure-scan.eval.yaml \
114+
--grader-plugin "$PWD/.github/workflows/evals/kbe-candidate-reads-grader.mjs" --strict
108115
vally eval --eval-spec .github/workflows/evals/ci-failure-scan.eval.yaml \
116+
--grader-plugin "$PWD/.github/workflows/evals/kbe-candidate-reads-grader.mjs" \
109117
--skill-dir .github/workflows --workspace /tmp/ws --output-dir /tmp/out
110118
```

‎.github/workflows/evals/ci-failure-scan.eval.yaml‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: ci-failure-scan-eval
2-
description: Dynamic gate for the CI failure scanner's safe-output, run with the maintainer /ci-scan eval command. It runs the scanner against a live outer-loop failure it discovers itself, then checks the emitted Known Build Error against the static KBE format and that it actually grounded the KBE in a real failing build.
2+
description: Dynamic gate for the CI failure scanner's safe-output, run with the maintainer /ci-scan eval command. It runs the scanner against a live outer-loop failure it discovers itself, then checks either the emitted Known Build Error or an explicit no-new-KBE result, and verifies that it actually grounded the decision in a real failing build.
33

44
defaults:
55
judge_model: claude-opus-4.8
@@ -24,7 +24,7 @@ environment:
2424
- --env
2525
- GITHUB_READ_ONLY=1
2626
- --env
27-
- GITHUB_TOOLSETS=pull_requests,repos,issues,search
27+
- GITHUB_TOOLSETS=pull_requests,repos,issues
2828
- ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4
2929

3030
stimuli:
@@ -48,9 +48,11 @@ stimuli:
4848
1. Find a recent failed build on `main` of an outer-loop pipeline via the dnceng-public AzDO REST API. It is anonymous and needs no auth, so stay on `_apis/build/...`. Start with `runtime-extra-platforms`, which is definition 154, and fall back to other outer-loop defs if needed.
4949
`curl -fsSL "https://dev.azure.com/dnceng-public/public/_apis/build/builds?definitions=154&branchName=refs/heads/main&statusFilter=completed&resultFilter=failed&\$top=5&api-version=7.1"`
5050
2. Open that build's timeline at `/_apis/build/builds/{id}/timeline?api-version=7.1`, pick a failed leg, and read its log at `/_apis/build/builds/{id}/logs/{logId}?api-version=7.1` to extract a concrete error or assertion signature. Save the leg log you extract from as `failure.log` under /tmp and grep it for your signature to get the hit count.
51-
3. Before filing, check whether an equivalent KBE already exists so you would not duplicate it. Run `gh search issues 'in:title [ci-scan]' --repo dotnet/runtime --state open` and also search by your signature text. Assume you would file only if none matches.
51+
3. Before filing, check whether an equivalent KBE already exists so you would not duplicate it. For this eval, this two-query step replaces the broader issue-search variants in the copied shared instructions; do not run additional issue-search variants. The eval runner does not launch workflow-frontmatter MCP servers, so invoke the trusted CLI harness exposed through `KBE_SEARCH_HELPER` with `node "$KBE_SEARCH_HELPER" '<query>'`. Use two bounded queries: one containing `in:title "[ci-scan]"` plus the discovered failure signature or test-family stem, and one containing `"[ci-scan]"` plus the most distinctive signature text. Choose terms specific enough that each query returns at most 10 complete matches; do not use an unbounded `[ci-scan]`-only query or retry after a helper error. For every candidate returned by either query, call the GitHub `issue_read` tool with `owner: dotnet`, `repo: runtime`, and `method: get` before deciding whether it matches. Do not use any other shell issue search or the built-in `search_issues` tool. Assume you would file only if every readable candidate is unrelated; fail closed if a candidate cannot be read.
5252
53-
Then **emit the create-issue safe-output you would file, as a single markdown document at `./out/kbe.md`**, in exactly this shape.
53+
If every actionable failure is already represented by an existing KBE, or no actionable failure can be identified, write exactly `Result: No new Known Build Error` to `./out/kbe.md` and do not emit a create-issue request.
54+
55+
Otherwise, **emit the create-issue safe-output you would file, as a single markdown document at `./out/kbe.md`**, in exactly this shape.
5456
5557
- First line is `Title: [ci-scan] <one stable failure shape>`, an assertion string or test family with no build numbers, dates, queue or machine names, or run IDs.
5658
- Second line is `Labels: Known Build Error, <blocking-clean-ci|blocking-clean-ci-optional>`. Pick exactly one blocking label. The JIT, GC, and PGO stress pipelines are `optional-ci` and use `blocking-clean-ci-optional`, and the required gate uses `blocking-clean-ci`.
@@ -61,38 +63,40 @@ stimuli:
6163
6264
rubric:
6365
- |
64-
The emitted `./out/kbe.md` conforms to the static Known Build Error format. It has a `[ci-scan] ` title naming exactly one failure shape with no run-specific IDs, the `Known Build Error` label plus exactly one of `blocking-clean-ci` or `blocking-clean-ci-optional`, the three body sections, a collapsed `KBE authoring guidance (ci-failure-scan)` block, and exactly one fenced json block with all four keys where exactly one of ErrorMessage or ErrorPattern is a non-empty specific substring rather than a bare identifier and the other is `""`. The Build Information section cites a concrete real build id. After the JSON, it has a collapsed `Agentic workflow metadata (ci-failure-scan)` block identifying the `kbe-verification` artifact and a positive verified match count.
66+
The emitted `./out/kbe.md` is either exactly `Result: No new Known Build Error` when the live scan has no actionable new KBE, or it conforms to the static Known Build Error format. A KBE has a `[ci-scan] ` title naming exactly one failure shape with no run-specific IDs, the `Known Build Error` label plus exactly one of `blocking-clean-ci` or `blocking-clean-ci-optional`, the three body sections, a collapsed `KBE authoring guidance (ci-failure-scan)` block, and exactly one fenced json block with all four keys where exactly one of ErrorMessage or ErrorPattern is a non-empty specific substring rather than a bare identifier and the other is `""`. The Build Information section cites a concrete real build id. After the JSON, it has a collapsed `Agentic workflow metadata (ci-failure-scan)` block identifying the `kbe-verification` artifact and a positive verified match count.
67+
- |
68+
The trajectory invokes only the `search-kbe-issues` CLI harness for issue searches, parses every candidate returned by both required queries, and successfully calls `issue_read` for every returned issue number before deciding that no candidate matches. If any candidate cannot be read, the run fails closed instead of emitting `./out/kbe.md`.
6569
6670
graders:
6771
- type: file-matches
6872
config:
6973
path: "out/kbe.md"
70-
pattern: '(?im)^Title:\s*\[ci-scan\]\s+\S'
74+
pattern: '(?ims)(?:(?<![\s\S])\s*Result:\s*No new Known Build Error\s*(?![\s\S])|Title:\s*\[ci-scan\]\s+\S)'
7175

7276
- type: file-matches
7377
config:
7478
path: "out/kbe.md"
75-
pattern: '(?im)^Labels:\s*Known Build Error,\s*blocking-clean-ci(-optional)?\s*$'
79+
pattern: '(?ims)(?:(?<![\s\S])\s*Result:\s*No new Known Build Error\s*(?![\s\S])|Labels:\s*Known Build Error,\s*blocking-clean-ci(-optional)?\s*$)'
7680

7781
- type: file-matches
7882
config:
7983
path: "out/kbe.md"
80-
pattern: '(?s)##\s+Build Information.*##\s+Error Details.*##\s+Error Message'
84+
pattern: '(?ims)(?:(?<![\s\S])\s*Result:\s*No new Known Build Error\s*(?![\s\S])|##\s+Build Information.*##\s+Error Details.*##\s+Error Message)'
8185

8286
- type: file-matches
8387
config:
8488
path: "out/kbe.md"
85-
pattern: '(?s)```json\s*\{.*"ErrorMessage".*"ErrorPattern".*"BuildRetry".*"ExcludeConsoleLog".*\}\s*```'
89+
pattern: '(?ims)(?:(?<![\s\S])\s*Result:\s*No new Known Build Error\s*(?![\s\S])|```json\s*\{.*"ErrorMessage".*"ErrorPattern".*"BuildRetry".*"ExcludeConsoleLog".*\}\s*```)'
8690

8791
- type: file-matches
8892
config:
8993
path: "out/kbe.md"
90-
pattern: '(?s)<details>\s*<summary>KBE authoring guidance \(ci-failure-scan\)</summary>.*?</details>'
94+
pattern: '(?ims)(?:(?<![\s\S])\s*Result:\s*No new Known Build Error\s*(?![\s\S])|<details>\s*<summary>KBE authoring guidance \(ci-failure-scan\)</summary>.*?</details>)'
9195

9296
- type: file-matches
9397
config:
9498
path: "out/kbe.md"
95-
pattern: '(?s)<details>\s*<summary>Agentic workflow metadata \(ci-failure-scan\)</summary>\s*Workflow artifact:\s*ci-failure-scan\s*Artifact kind:\s*kbe-verification\s*Verified match count:\s*[1-9]\d*\s+hits in failure\.log\s*</details>'
99+
pattern: '(?ims)(?:(?<![\s\S])\s*Result:\s*No new Known Build Error\s*(?![\s\S])|<details>\s*<summary>Agentic workflow metadata \(ci-failure-scan\)</summary>\s*Workflow artifact:\s*ci-failure-scan\s*Artifact kind:\s*kbe-verification\s*Verified match count:\s*[1-9]\d*\s+hits in failure\.log\s*</details>)'
96100

97101
- type: file-not-matches
98102
config:
@@ -111,7 +115,15 @@ stimuli:
111115
config:
112116
required:
113117
- name: '^(bash|powershell)$'
114-
command: 'gh (search|issue)'
118+
command: '\$KBE_SEARCH_HELPER'
119+
min_count: 2
120+
disallowed:
121+
- name: '^(bash|powershell)$'
122+
command: '(gh (search|issue)|gh\s+api\s+search/issues|api\.github\.com/search/issues)'
123+
- name: 'search_issues'
124+
125+
- type: kbe-candidate-reads
126+
name: read-every-search-candidate
115127

116128
- type: prompt
117129
name: kbe-format-conformant

‎.github/workflows/evals/kbe-candidate-reads-grader.mjs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,21 @@ function isSearchHarnessCall(event) {
9999
return false;
100100
}
101101

102-
const normalizedCommand = command.replaceAll("\\", "/");
102+
const normalizedCommand = command
103+
.replaceAll("\\", "/")
104+
.replaceAll("\r\n", "\n")
105+
.replaceAll("\r", "\n");
103106
const normalizedPath = (process.env.KBE_SEARCH_HELPER ?? trustedHelperPath).replaceAll("\\", "/");
104107
const escapedPath = normalizedPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108+
const commandBoundary = String.raw`(?:^\s*|(?:&&|\|\||[;\n])\s*)`;
105109
const invocation = new RegExp(
106-
`^\\s*node(?:\\.exe)?\\s+(?:"${escapedPath}"|'${escapedPath}'|${escapedPath})(?:\\s|$)`,
110+
`${commandBoundary}node(?:\\.exe)?\\s+(?:"${escapedPath}"|'${escapedPath}'|${escapedPath})(?:\\s|$)`,
111+
);
112+
const environmentInvocation = new RegExp(
113+
`${commandBoundary}node(?:\\.exe)?\\s+"\\$KBE_SEARCH_HELPER"(?:\\s|$)`,
107114
);
108115

109-
return invocation.test(normalizedCommand);
116+
return invocation.test(normalizedCommand) || environmentInvocation.test(command);
110117
}
111118

112119
function isIssueReadCall(event) {

0 commit comments

Comments
 (0)