Let the build-failure analyst push fixes it cannot suggest inline - #55807
Let the build-failure analyst push fixes it cannot suggest inline#55807YuliiaKovalova wants to merge 6 commits into
Conversation
GitHub only accepts `suggestion` blocks on lines that are part of a PR's diff. Dependency-flow PRs break exactly that assumption: their diff is nothing but version files, so when a flowed package changes an API and previously-untouched call sites stop compiling, the analysis can only describe the fix and ask a human to commit it (see dotnet/arcade#17348 (comment)). Enable the `push-to-pull-request-branch` safe output on the automatic build-failure-analysis workflow so the agent can append the fix commit instead, with narrow guardrails: * `allowed-files` is an exclusive allowlist, so build infrastructure is out of reach; `protected-files` keeps its default blocked policy. * gh-aw refuses pushes to fork branches, which bounds `roles: all` to same-repo branches (dependency flow + write-access humans). * `max: 1` plus a `[build-failure-analysis]` commit-marker check in the agent playbook (Step 6b) stops a fail -> push -> ADO rebuild -> fail loop from converging on nothing. * Step 6b also requires the fix to be mechanical and provable from the compiler error; anything else stays a comment. Authoring the commit requires the PR's tree, so the agent job now checks out the PR head branch by name (`pr-checkout-ref`, resolved by the fetch job; forks fall back to `refs/pull/<n>/head`). A branch name is required because gh-aw derives the push target from `git rev-parse --abbrev-ref HEAD`. gh-aw's own base-branch config restore is gated on its built-in PR-checkout step, which never fires for `check_run`, so a second sparse checkout plus a `pre-agent-steps` step restores `.github`, `.agents` and the root instruction files from the base branch before the agent starts. No PR code is built or executed: the bash allowlist gains only scoped `git status/diff/log/rev-parse/add/commit`, and the push itself is performed by the safe-outputs job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Azure Pipelines: Successfully started running 2 pipeline(s). 1 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR updates the automatic build-failure-analysis GitHub Actions workflow (gh-aw based) so the build-failure analyst can append a mechanical fix commit to the PR branch in the specific case where GitHub inline suggestion blocks are structurally impossible (because the fix is in files outside the PR diff, e.g. dependency-flow PRs). It also adds checkout/prompt-injection guardrails so the agent authors commits against the PR head while still loading agent configuration from the base branch.
Changes:
- Enable
push-to-pull-request-branchas a safe-output tool in the automatic workflow, withsrc/**+test/**allowlisting and loop-guard guidance. - Add PR-head checkout resolution (
pr-checkout-ref) so any authored fix commit applies to the PR’s actual tree (branch-attached for same-repo PRs, detached for forks). - Restore
.github/+.agents/from base branch before the agent runs to mitigate PR-controlled instruction/prompt injection.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| .github/workflows/shared/build-failure-analysis-shared.md | Updates shared analyst instructions to describe the push escape hatch and when it is allowed. |
| .github/workflows/shared/build-failure-analysis-fetch.md | Adds pr-checkout-ref output and logic to select a safe checkout ref for PR-head analysis. |
| .github/workflows/build-failure-analysis.md | Enables push safe-output and introduces PR-head checkout + base-branch config restore guardrail. |
| .github/workflows/build-failure-analysis.lock.yml | Regenerated lock workflow reflecting new safe-output tool, permissions, checkouts, and handler wiring. |
| .github/workflows/build-failure-analysis-command.lock.yml | Regenerated lock workflow to align with updated shared fetch outputs (still comment-only). |
| .github/agents/build-failure-analyst.agent.md | Extends the analyst playbook with Step 6b for pushing a mechanical fix commit under strict conditions. |
- Restore of the root agent-config files now consults the base-branch tree (git ls-tree) instead of trusting the sparse checkout to materialize them, so a sparse-checkout change can never turn "restore" into "delete". - Step 6b's loop guard now explicitly reads the PR's commit list through the GitHub tools; the PR-head checkout is depth-1, so git log cannot see it. - Correct the ools: comment: gh-aw itself widens the shell allowlist with git branch/checkout/merge/rm/switch when push-to-pull-request-branch is enabled. git push is still absent, and the playbook forbids the injected verbs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
Address the second round of review feedback: - Enforce the one-attempt loop guard deterministically instead of relying on the agent obeying a prompt. The fetch job now scans the PR's commits for the `[build-failure-analysis]` marker and publishes a `push-blocked` output; when it is set, `pre-agent-steps` installs a `pre-commit` hook via `core.hooksPath` that refuses every commit. `git config` is not in the agent's tool allowlist, so the agent cannot undo it, and gh-aw builds its patch from agent commits - with no commit there is nothing to push. - Restore the complete set of agent-config paths from the base branch, not just a subset. The sparse-checkout and the restore loops now cover gh-aw's full folder list (.agents .antigravity .claude .codex .crush .gemini .github .opencode .pi) and root files (.crush.json .mcp.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc), closing the gap where a PR could ship an unrestored instruction file. - Document that `GH_AW_CI_TRIGGER_TOKEN` is an optional gh-aw magic secret that is deliberately left unset: it only exists to re-trigger GitHub Actions CI on the pushed commit, and our CI runs in Azure DevOps which rebuilds on its own. Unset, the token is empty and the extra empty-commit step is skipped, so no configuration is required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
The previous loop guard did not actually work. It pointed `core.hooksPath`
at `${RUNNER_TEMP}/gh-aw-refuse-commits`, but the agent firewall only
mounts `${RUNNER_TEMP}/gh-aw` and the workspace, so git saw a nonexistent
hooks directory and committed anyway - and even with the directory
mounted, the allowed `git commit:*` permits `--no-verify`. Anything
installed inside the agent's sandbox is a suggestion, not a guarantee.
Move the decision entirely into trusted workflow code:
- The fetch job publishes `push-blocked`, and the automatic workflow's
job-level `if:` consumes it. When it is true the activation and agent
jobs never run, and gh-aw's `safe_outputs` job (conditioned on the
agent not being skipped) is skipped with them, so no push code path
remains. The `/analyze-build-failure` command workflow is comment-only
and ignores the output.
- Stamp the `[build-failure-analysis]` marker with `commit-title-suffix`
so gh-aw's push handler appends it while applying the patch. The guard
no longer depends on the model remembering, or correctly spelling, a
marker of its own; the playbook now tells the agent not to write one.
- Make the check fail closed and read the branch tip directly. It used
`gh api ... 2>/dev/null | grep -q`, so a transient API error silently
produced "not blocked"; it now defaults to blocked and only clears
after the tip commit was read successfully. The tip is resolved from
the pull request's `head.sha` rather than the ambient `HEAD_SHA`, which
can hold the check run's merge commit. Fork pull requests are exempt -
gh-aw refuses to push to them, so the guard must not suppress their
comment-only analysis.
- Scope the guard to the branch tip instead of the whole history, so a
pull request is not abandoned forever after one automated attempt: any
later commit by anyone restores full analysis.
Also fix a genuine defect introduced in the previous commit: two `echo`
statements had been joined onto a single line in the fetch job's outputs
block, which would have written a malformed `$GITHUB_OUTPUT` entry and
lost `ado-build-id`.
Finally, stop the playbook from asking for a second summary comment.
Step 5 already posts exactly one; it is now posted after the push is
requested and describes the commit as requested and pending rather than
completed, since the push happens in a later job and can still fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Addressed the latest review round (including the suppressed comments, which contained the most important findings). The loop guard did not actually work, so it was rebuilt in trusted code.
The check fails closed now. It previously ran Scoped to the branch tip, not the whole history, so one automated attempt does not disable analysis on that PR forever: any later commit by anyone restores it. Real bug fixed. Two No duplicate summary comment. Step 5 posts exactly one; it is now posted after the push is requested and describes the commit as requested and pending rather than completed, since the push runs in a later job and can still be rejected. |
…port Review round 4: * `target: "*"` left the pull request number inside the agent's own tool call: gh-aw's handler resolves the *supplied* number and only then checks whether that pull request is a fork, so an injected agent could aim the push at an unrelated same-repo pull request and escape both the fork boundary and the loop guard. The target is now bound to `check_run.pull_requests[0].number`, a field GitHub fills in itself and that is never routed through the model. Because GitHub leaves it empty for fork-originated check runs, this also removes the push target entirely on fork pull requests while leaving the comment-only analysis untouched (verified against live check runs in all three repositories: same-repo pull requests report exactly one entry, fork ones report none). * `patch-format` defaults to `bundle`, and gh-aw's handler only rewrites commit titles on the `git am` path, so `commit-title-suffix` was silently a no-op: the `[build-failure-analysis]` marker would never have been stamped and the loop guard that keys off it would never have fired. Pinned to `patch-format: am`. * The agent-config restore checked out `github.event.repository.default_branch` rather than the pull request's own base branch, so a release-branch pull request was analysed with `main`'s playbook and agent instructions. It now uses the base ref the fetch job already resolves from the GitHub API, falling back to the default branch when that lookup returns nothing. * The fetch job now refuses the run outright when the check payload and the Azure Pipelines build name different pull requests, so the push target and the loop guard can never end up scoped to different branches. * Reworded a comment that claimed a deliberately non-fatal diagnostic listing would "fail loudly". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Round 4 of review feedback, applied to all three repos (arcade #17353, testfx #10607, sdk #55807). 1. The push target was model-controlled. With Because GitHub leaves that field empty for fork-originated check runs, this removes the push target entirely on fork PRs while leaving the comment-only analysis untouched. Verified against live check runs in all three repos: same-repo PRs report exactly one entry, fork PRs report none. 2. 3. The agent-config restore used the wrong branch. It checked out 4. Target/guard consistency. The fetch job prefers the ADO build's source branch when resolving the PR number. If that ever disagreed with the check payload, the guard and the push would be scoped to different branches, so the run is now refused outright in that case. 5. Reworded a comment that claimed a deliberately non-fatal diagnostic listing would "fail loudly". All locks recompiled |
…bject
Review round 5: the guard matched `[build-failure-analysis]` anywhere in the
tip commit subject, which can false-positive on an unrelated commit that
happens to quote the marker.
It now requires the leading space the handler always inserts, so a subject
that merely opens with the marker no longer counts as an automated fix.
It is deliberately still not anchored to the end of the subject. gh-aw
appends the suffix by rewriting the first `Subject:` line of a
`git format-patch` mbox, and git folds subjects longer than ~72 characters
onto continuation lines, so `git am` reassembles the title with the marker in
the middle:
Fix CS1503 after [build-failure-analysis] Microsoft.DotNet.Product...
Verified end to end against real `git format-patch` output rewritten with the
handler's own regex: the resulting commit subject contains the marker but
does not end with it. Anchoring would silently miss those commits and let the
fail -> push -> rebuild -> fail loop run unbounded, which is the one direction
this guard must never fail in. A false positive only skips one analysis; a
false negative removes the brake.
The reasoning is now recorded next to the check so it is not "simplified"
later, and the analyst playbook asks for commit titles of 60 characters or
fewer so the marker lands at the end in practice.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Round 5. One finding this time (arcade, suppressed): the loop guard matched Tightened, but deliberately not anchored — anchoring would have introduced a fail-open bug. gh-aw appends the suffix by rewriting the first So an end-of-subject anchor would silently miss exactly the commits the guard exists to catch, and the fail → push → rebuild → fail loop would run unbounded. The asymmetry matters: a false positive skips one analysis (and any later commit on the branch restores it), while a false negative removes the brake entirely. What did change:
Also confirmed there is nothing outstanding elsewhere: the two sdk threads ( All locks recompiled |
There was a problem hiding this comment.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
Note
This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.
There was a problem hiding this comment.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
Note
This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.
Why
GitHub only accepts inline
suggestionblocks on lines that are part of a PR's diff. Dependency-flow PRs break exactly that assumption: their diff is nothing but version files, so when a flowed package changes an API and previously-untouched call sites stop compiling, the build-failure analyst can only describe the fix and ask a human to commit it.Concrete example that motivated this: dotnet/arcade#17348 (comment) — the analysis correctly identified two
CS1503s inPublishBuildToMaestro.cs(a flowedMicrosoft.DotNet.ProductConstructionService.Clientinserted a parameter beforeCancellationToken) but had to end with "the fix needs to be committed separately", because that file is not in the dependency-bump diff.What
Enable the
push-to-pull-request-branchsafe output on the automaticbuild-failure-analysisworkflow (the/analyze-build-failurecommand workflow is deliberately left comment-only), so the agent can append the fix commit when — and only when — a suggestion structurally cannot carry it.Guardrails
target: "*"gh-aw takes the pull request number out of the agent's own tool call and only then checks whether that pull request is a fork, so an injected agent could redirect the push at an unrelated same-repo PR. The target is instead bound tocheck_run.pull_requests[0].number, which GitHub fills in itself and which never passes through the model.check_run.pull_requestsempty for fork-originated check runs, so a fork PR has no push target at all (verified against live check runs in all three repos: same-repo PRs report exactly one entry, fork ones report none) — and gh-aw's handler refuses fork branches regardless. That boundsroles: allto same-repo branches: dependency flow plus people who already have write access. Pushes are append-only; force-push is impossible. The comment-only analysis still runs on fork PRs.allowed-filesis an exclusive allowlist, so build infrastructure (eng/,global.json,NuGet.config,.github/) is out of reach no matter what the agent produces;protected-fileskeeps its default blocked policy on top.max: 1plus a loop guard enforced entirely in trusted code.commit-title-suffixmakes gh-aw's push handler stamp[build-failure-analysis]onto the commit title as it applies the patch, so the marker is written by the handler and never by the model. This requirespatch-format: am: the defaultbundletransport never rewrites commit titles, which would have made the marker — and therefore the guard — a silent no-op. Before anything else runs, the fetch job reads the PR's head commit; if that tip is already such a commit and the build still fails, it publishespush-blocked, and the workflow's job-levelif:skips the activation and agent jobs — gh-aw'ssafe_outputsjob is itself conditioned on the agent not being skipped, so no push code path remains. The check fails closed (an unreadable commit blocks) and is scoped to the branch tip, so any later commit by anyone restores full analysis rather than abandoning the PR after one attempt. The push usesGITHUB_TOKEN, which does not re-trigger GitHub Actions — but the Azure DevOps GitHub app does rebuild, so this guard is the real brake on a fail → push → rebuild → fail loop.fallback-as-pull-request: falseso a diverged branch cannot silently become a surprise PR.Supporting changes
push-to-pull-request-branchships file contents, so a fix authored againstmainwould silently revert whatever else changed in that file. The fetch job now resolvespr-checkout-ref: the head branch name for same-repo PRs (gh-aw derives the push target fromgit rev-parse --abbrev-ref HEAD, so a detached SHA checkout would break bundle generation), falling back torefs/pull/<n>/headfor forks.check_run(that event carries nopull_requestpayload). Checking out the PR head therefore puts PR-controlled agent-config content in the workspace — where the analyst reads its own playbook. A second sparse checkout of the base branch plus apre-agent-stepsstep restores it before the agent starts, replaying gh-aw's inline sub-agent/skill restores afterwards. That checkout uses the PR's own base ref (resolved from the GitHub API by the fetch job), not the repository default branch, so a release-branch PR is analysed with the playbook that branch actually carries. The restore covers gh-aw's full protected set — folders.agents .antigravity .claude .codex .crush .gemini .github .opencode .piand root files.crush.json .mcp.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc— including paths that do not exist on the base branch, which are deleted rather than left as PR-authored content.GH_AW_CI_TRIGGER_TOKENappears in the lock because gh-aw wires that magic secret into every workflow using this safe output; it only exists to add an empty commit under a PAT so GitHub Actions CI re-runs on the pushed commit. It is deliberately left unset — our CI is Azure DevOps, which rebuilds on its own — and when unset the value is empty and gh-aw simply skips that step.git status/diff/log/rev-parse/add/commit— no interpreters, package managers or build tools. Note that gh-aw itself appendsgit branch/checkout/merge/rm/switchto the generated--allow-toollist wheneverpush-to-pull-request-branchis enabled; that cannot be narrowed from the workflow config, so the analyst playbook forbids those commands explicitly.git pushis not injected — the agent can never write to the remote, and the push is performed by thesafe_outputsjob from a bundle of the agent's local commits.Validation
--strict, clean.contents: writeis added only to thesafe_outputsandconclusionjobs, the agent job stays least-privilege.git bundleof the incremental commit range works from the shallow agent checkout.Residual risks