Summary
When the agent checks out the target repo shallowly and the base branch advances during the run, pushSignedCommits produces a PR commit whose parent is current origin/<base> but whose tree comes from the agent's stale-base checkout. This silently reverts every intervening commit in the affected files and the resulting PR contains dozens-to-hundreds of file changes the agent never asked for. The protected_files policy is also bypassed because it is evaluated against the agent's emitted patch, not against the synthesized GraphQL fileChanges payload.
Concrete repro
| Fact |
Value |
| PR commit |
53829c3f360d090447237acd79707c7e6f99a477 |
| PR commit parent |
dc55a3747c9c38503e62f90af61796290d8169ea (current master) |
| PR commit committer |
web-flow (signed via GraphQL createCommitOnBranch) |
| PR commit files changed |
99 (+7323 / -2263) |
Agent's actual patch (from safe-output-items.jsonl artifact) |
1 file — test/integration/blob_controller_test.rb, ~14 lines |
| Agent's bundle tip SHA |
f1ddbe37b9b9b444815e935cd4c2401275735151 (never reachable via API) |
| Agent's bundle declared prerequisite |
07028dfaa35b484328b7f8dbe32ee5c36d152ad3 (2026-06-04T10:23:10Z) |
master was ahead of that prerequisite by |
178 commits at safe-outputs time |
compare 07028dfa...master → ahead_by: 178, files: 98. That matches the PR's 99 changed files almost exactly (98 from staleness + 1 from the agent's edit).
Mechanism
The workflow uses actions/checkout@v6 with fetch-depth: 20 on a high-churn monorepo (github/github advances dozens of commits per hour). The agent's local "master" was more than 3 hours stale by the time the safe-outputs job ran. The generated safe-outputs job re-checks-out master with fetch-depth: 20, but the bundle commits it unpacks still record their original (stale) first parent.
In actions/setup/js/push_signed_commits.cjs, pushSignedCommits:
- Resolves
expectedHeadOid from git ls-remote origin refs/heads/<branch> / baseRefOid — i.e. current origin/<base>.
- For each commit in
origin/<base>..HEAD, runs git diff-tree -r --raw <sha> against the commit's own first parent (the stale base baked into the bundle) and packages those into fileChanges: { additions, deletions }.
- Calls the GraphQL
createCommitOnBranch mutation with that combination.
GitHub then writes a new commit whose parent is current <base> but whose tree is parent_tree + agent_fileChanges. Because agent_fileChanges were computed against a 178-commit-old tree, every file where stale_<base>_tree differs from current_<base>_tree gets silently reverted to the stale content.
Protection bypass
The workflow has:
safe-outputs:
create-pull-request:
max_patch_files: 100
protected_files: [..., "CODEOWNERS", ...]
protected_files_policy: "blocked"
CODEOWNERS and .rubocop_todo.yml (+6775 lines, reverting a recent cleanup) both ended up in the PR. The protection check inspects the agent's emitted patch (1 file) — not the synthesized fileChanges payload actually sent to GraphQL — so it has no chance to fire.
Why this is severe
- The PR title/body/commit message describe a 1-file change. A reviewer skimming the description has no signal that 98 unrelated files were touched, and several of those reverts would be silently merged if auto-merge or trust were enabled.
protected_files is an explicit safety net that users rely on. It is bypassed without warning.
- Any gh-aw workflow that targets a high-churn repo with the documented
fetch-depth examples is exposed.
Suggested fix (two parts — both are needed)
A natural first instinct is "just deepen the safe-outputs checkout". That alone is not sufficient: even with a full clone, pushSignedCommits still computes fileChanges from diff-tree against each bundle commit's recorded first parent (the stale base), and still sends that payload to a GraphQL mutation whose expectedHeadOid is current <base>. The 99-file PR would still happen.
The fix is the pair:
1. Deepen until the bundle's base is reachable
In the generated safe-outputs job, after unbundling the agent's commits, fetch origin/<base> until the bundle's recorded base SHA (the prerequisite encoded in the .bundle header, or the first parent of the bundle's tip) is reachable locally. Either:
- iterate
git fetch origin <base> --deepen=<N> until git merge-base --is-ancestor <bundle-base> origin/<base> succeeds, or
- run
git fetch origin <base> --unshallow once when shallow.
Without this, step 2 has no merge base to work with.
2. Compute fileChanges against current origin/<base> (the GraphQL parent)
In pushSignedCommits, after step 1 has made the merge base reachable, either:
- Rebase the bundle onto current
origin/<base> and then build fileChanges per rebased commit (each commit's first parent now matches what GraphQL will use), or
- Build a single combined diff with
git diff <merge-base>...<bundle-tip> (three-dot — diff from the merge base, not from the agent's recorded stale parent) and send that as a single createCommitOnBranch call.
If the rebase / cherry-pick has conflicts, refuse the push with a clear error and surface it to the user — silently inventing 98 file changes is the failure mode being fixed.
3. Validate the synthesized payload, not the agent's input
Run protected_files, max_patch_files, and max_patch_size against the final fileChanges array that is about to be sent to GraphQL, not only against the agent's emitted patch. This is a defense-in-depth check that would have caught this incident even without fixes 1 and 2.
4. Default fetch-depth guidance
Until fixes 1 and 2 ship, the example/template workflows for high-churn targets should default to fetch-depth: 0 (or auto-deepen on demand) rather than 20.
Artifacts available
I have the workflow run artifacts saved locally and can attach: the agent's aw-*.patch (1 file), the agent's .bundle (declares 07028dfa as prerequisite), and the safe-output-items.jsonl containing the create_pull_request item.
Summary
When the agent checks out the target repo shallowly and the base branch advances during the run,
pushSignedCommitsproduces a PR commit whose parent is currentorigin/<base>but whose tree comes from the agent's stale-base checkout. This silently reverts every intervening commit in the affected files and the resulting PR contains dozens-to-hundreds of file changes the agent never asked for. Theprotected_filespolicy is also bypassed because it is evaluated against the agent's emitted patch, not against the synthesized GraphQLfileChangespayload.Concrete repro
v0.78.153829c3f360d090447237acd79707c7e6f99a477dc55a3747c9c38503e62f90af61796290d8169ea(currentmaster)web-flow(signed via GraphQLcreateCommitOnBranch)safe-output-items.jsonlartifact)test/integration/blob_controller_test.rb, ~14 linesf1ddbe37b9b9b444815e935cd4c2401275735151(never reachable via API)07028dfaa35b484328b7f8dbe32ee5c36d152ad3(2026-06-04T10:23:10Z)masterwas ahead of that prerequisite bycompare 07028dfa...master→ahead_by: 178, files: 98. That matches the PR's 99 changed files almost exactly (98 from staleness + 1 from the agent's edit).Mechanism
The workflow uses
actions/checkout@v6withfetch-depth: 20on a high-churn monorepo (github/githubadvances dozens of commits per hour). The agent's local "master" was more than 3 hours stale by the time the safe-outputs job ran. The generated safe-outputs job re-checks-outmasterwithfetch-depth: 20, but the bundle commits it unpacks still record their original (stale) first parent.In
actions/setup/js/push_signed_commits.cjs,pushSignedCommits:expectedHeadOidfromgit ls-remote origin refs/heads/<branch>/baseRefOid— i.e. currentorigin/<base>.origin/<base>..HEAD, runsgit diff-tree -r --raw <sha>against the commit's own first parent (the stale base baked into the bundle) and packages those intofileChanges: { additions, deletions }.createCommitOnBranchmutation with that combination.GitHub then writes a new commit whose parent is current
<base>but whose tree isparent_tree + agent_fileChanges. Becauseagent_fileChangeswere computed against a 178-commit-old tree, every file wherestale_<base>_treediffers fromcurrent_<base>_treegets silently reverted to the stale content.Protection bypass
The workflow has:
CODEOWNERSand.rubocop_todo.yml(+6775 lines, reverting a recent cleanup) both ended up in the PR. The protection check inspects the agent's emitted patch (1 file) — not the synthesizedfileChangespayload actually sent to GraphQL — so it has no chance to fire.Why this is severe
protected_filesis an explicit safety net that users rely on. It is bypassed without warning.fetch-depthexamples is exposed.Suggested fix (two parts — both are needed)
A natural first instinct is "just deepen the safe-outputs checkout". That alone is not sufficient: even with a full clone,
pushSignedCommitsstill computesfileChangesfromdiff-treeagainst each bundle commit's recorded first parent (the stale base), and still sends that payload to a GraphQL mutation whoseexpectedHeadOidis current<base>. The 99-file PR would still happen.The fix is the pair:
1. Deepen until the bundle's base is reachable
In the generated safe-outputs job, after unbundling the agent's commits, fetch
origin/<base>until the bundle's recorded base SHA (the prerequisite encoded in the.bundleheader, or the first parent of the bundle's tip) is reachable locally. Either:git fetch origin <base> --deepen=<N>untilgit merge-base --is-ancestor <bundle-base> origin/<base>succeeds, orgit fetch origin <base> --unshallowonce when shallow.Without this, step 2 has no merge base to work with.
2. Compute
fileChangesagainst currentorigin/<base>(the GraphQL parent)In
pushSignedCommits, after step 1 has made the merge base reachable, either:origin/<base>and then buildfileChangesper rebased commit (each commit's first parent now matches what GraphQL will use), orgit diff <merge-base>...<bundle-tip>(three-dot — diff from the merge base, not from the agent's recorded stale parent) and send that as a singlecreateCommitOnBranchcall.If the rebase / cherry-pick has conflicts, refuse the push with a clear error and surface it to the user — silently inventing 98 file changes is the failure mode being fixed.
3. Validate the synthesized payload, not the agent's input
Run
protected_files,max_patch_files, andmax_patch_sizeagainst the finalfileChangesarray that is about to be sent to GraphQL, not only against the agent's emitted patch. This is a defense-in-depth check that would have caught this incident even without fixes 1 and 2.4. Default
fetch-depthguidanceUntil fixes 1 and 2 ship, the example/template workflows for high-churn targets should default to
fetch-depth: 0(or auto-deepen on demand) rather than20.Artifacts available
I have the workflow run artifacts saved locally and can attach: the agent's
aw-*.patch(1 file), the agent's.bundle(declares07028dfaas prerequisite), and thesafe-output-items.jsonlcontaining thecreate_pull_requestitem.