Summary
The current safe-outputs pipeline uses git format-patch / git am to transfer commits from the agent job to the safe-outputs job. This pipeline fundamentally cannot handle merge commits (format-patch silently skips them) or rebased history (SHAs diverge, breaking incremental patch generation). This means agents cannot resolve merge conflicts on PRs — the only git operations that clear GitHub's conflict indicator (merge commits, rebases) are incompatible with the pipeline.
Separately, commits pushed via git push with GITHUB_TOKEN are unverified, while commits created through the GraphQL API (createCommitOnBranch) are signed/verified.
This issue proposes replacing the patch pipeline with a GraphQL-based commit approach that solves both problems at once, preserving the agent's exact commit history (individual commits, merge commits, and commit messages).
Current Architecture
Agent (Job A) Safe Outputs (Job B)
───────────── ────────────────────
agent makes commits fetch origin/<branch>
│ checkout origin/<branch>
▼ │
git format-patch ▼
origin/<branch>..<branch> git am --3way <patch>
--stdout > patch │
│ ▼
└──── patch file ─────► git push origin <branch>
Limitations:
format-patch skips merge commits (multiple parents) → empty/incomplete patches
- Rebased branches break incremental mode (
origin/<branch> is no longer an ancestor)
- Commits pushed via
git push are unverified
Proposed Architecture
Replace the patch pipeline with per-commit manifest capture + GraphQL API replay. The agent's exact commit sequence is preserved — regular commits, merge commits, commit messages, and order.
Agent (Job A) Safe Outputs (Job B)
───────────── ────────────────────
agent makes commits
(edit, merge, rebase, etc.)
│
MCP handler walks commit history:
git rev-list --reverse
origin/<branch>..HEAD
For each commit:
- message, parents, file changes
│
└──── manifest.json ───────► For each commit in order:
Regular? → createCommitOnBranch (signed ✓)
Merge? → mergeBranch (signed ✓)
+ fixup if resolution differs
Capture (MCP handler in Job A)
Walks the commit history and captures each commit individually — message, type (regular vs merge), and file changes relative to the first parent:
const commits = [];
const shas = execGitSync(
["rev-list", "--reverse", `origin/$main..HEAD`], { cwd }
).trim().split("\n").filter(Boolean);
for (const sha of shas) {
const parents = execGitSync(
["log", "-1", "--format=%P", sha], { cwd }
).trim().split(" ").filter(Boolean);
const message = execGitSync(
["log", "-1", "--format=%B", sha], { cwd }
).trim();
const isMerge = parents.length > 1;
// Diff against first parent — shows what THIS commit changed
const nameStatus = execGitSync(
["diff", "--name-status", parents[0], sha], { cwd }
).trim();
const files = [];
for (const line of nameStatus.split("\n").filter(Boolean)) {
const [status, ...pathParts] = line.split("\t");
const filePath = pathParts.join("\t");
if (status.startsWith("D")) {
files.push({ path: filePath, deleted: true });
} else {
const content = execGitSync(["show", `${sha}:${filePath}`], { cwd });
files.push({ path: filePath, content: Buffer.from(content).toString("base64") });
}
}
commits.push({
message,
isMerge,
mergeParent: isMerge ? parents[1] : null,
files,
});
}
const manifest = { commits };
Application (Safe outputs handler in Job B)
Replays each commit in order, using the appropriate GraphQL mutation:
for (const commit of manifest.commits) {
const head = await getCurrentHead(branch);
if (!commit.isMerge) {
// Regular commit → createCommitOnBranch (signed)
await createCommitOnBranch(branch, head, commit.message, commit.files);
} else {
// Merge commit → mergeBranch, then fixup if auto-merge differs from agent's resolution
const result = await mergeBranch(repoId, branch, commit.mergeParent);
if (result.success) {
// Auto-merge worked — check if it matches agent's resolution
if (commit.files.length > 0) {
const mergeHead = await getCurrentHead(branch);
const needsFixup = await filesDiffer(branch, commit.files);
if (needsFixup) {
await createCommitOnBranch(branch, mergeHead, commit.message, commit.files);
}
}
} else {
// Conflicts — 3-phase approach (all signed):
// 1. Accept base branch version for conflicting files
const mainFiles = await getFilesAtRef(owner, repo, commit.mergeParent,
commit.files.map(f => f.path));
await createCommitOnBranch(branch, head, "Sync with base branch", mainFiles);
// 2. mergeBranch — guaranteed to succeed now
await mergeBranch(repoId, branch, commit.mergeParent);
// 3. Apply agent's actual resolution
const mergeHead = await getCurrentHead(branch);
await createCommitOnBranch(branch, mergeHead, commit.message, commit.files);
}
}
}
What This Preserves
| Property |
Current (format-patch) |
Proposed (GraphQL replay) |
| Individual commits |
✅ |
✅ — one API call per agent commit |
| Commit messages |
✅ |
✅ — captured per commit |
| Commit order |
✅ |
✅ — rev-list --reverse |
| Merge commits |
❌ (skipped) |
✅ — mergeBranch creates real two-parent commits |
| Conflict resolutions |
❌ |
✅ — fixup commit applies agent's resolution if auto-merge differs |
| Rebase support |
❌ (SHA divergence) |
✅ — tree diff is topology-agnostic |
| Signed/verified commits |
❌ (git push) |
✅ — all via GraphQL |
Example: Agent Session Replay
Agent session: edit → commit → edit → commit → merge main → resolve conflicts → commit post-merge fix
Agent (Job A) Job B replay
────────── ────────────
commit "Fix parser bug" → createCommitOnBranch("Fix parser bug", files)
commit "Add tests" → createCommitOnBranch("Add tests", files)
merge origin/main (conflicts) → mergeBranch(branch, main)
resolve conflicts → if conflicts: 3-phase approach
commit "Update config" → createCommitOnBranch("Update config", files)
Each step is signed. The PR history matches what the agent did.
Files to Change
actions/setup/js/generate_git_patch.cjs → replace format-patch with per-commit manifest capture
actions/setup/js/push_to_pull_request_branch.cjs → replace git am + git push with GraphQL replay loop
actions/setup/js/safe_outputs_handlers.cjs → update MCP handler to emit manifest instead of patch
actions/setup/sh/generate_git_patch.sh → update or remove shell version
actions/setup/js/create_pull_request.cjs → same pattern for PR creation
Trade-offs
- File size: Manifest contains full file contents (base64), not diffs. Larger than patches for big changes.
createCommitOnBranch has API size limits.
- API rate limits: N GraphQL calls (one per commit) vs 1 git push. Unlikely to be an issue for typical agent sessions.
- Merge fixup commits: When the agent's conflict resolution differs from auto-merge, an extra fixup commit appears. Could be noted in the commit message.
Related Issues
This proposal addresses both issues: #18801 becomes moot (no more format-patch) and #18565 is resolved (all commits via GraphQL are signed).
Real-World Failure
See elastic/ai-github-actions-playground#590 (comment) — agent attempted to resolve merge conflicts but was blocked because the pipeline cannot handle merge commits or rebased history.
Summary
The current safe-outputs pipeline uses
git format-patch/git amto transfer commits from the agent job to the safe-outputs job. This pipeline fundamentally cannot handle merge commits (format-patch silently skips them) or rebased history (SHAs diverge, breaking incremental patch generation). This means agents cannot resolve merge conflicts on PRs — the only git operations that clear GitHub's conflict indicator (merge commits, rebases) are incompatible with the pipeline.Separately, commits pushed via
git pushwith GITHUB_TOKEN are unverified, while commits created through the GraphQL API (createCommitOnBranch) are signed/verified.This issue proposes replacing the patch pipeline with a GraphQL-based commit approach that solves both problems at once, preserving the agent's exact commit history (individual commits, merge commits, and commit messages).
Current Architecture
Limitations:
format-patchskips merge commits (multiple parents) → empty/incomplete patchesorigin/<branch>is no longer an ancestor)git pushare unverifiedProposed Architecture
Replace the patch pipeline with per-commit manifest capture + GraphQL API replay. The agent's exact commit sequence is preserved — regular commits, merge commits, commit messages, and order.
Capture (MCP handler in Job A)
Walks the commit history and captures each commit individually — message, type (regular vs merge), and file changes relative to the first parent:
Application (Safe outputs handler in Job B)
Replays each commit in order, using the appropriate GraphQL mutation:
What This Preserves
rev-list --reversemergeBranchcreates real two-parent commitsExample: Agent Session Replay
Agent session: edit → commit → edit → commit → merge main → resolve conflicts → commit post-merge fix
Each step is signed. The PR history matches what the agent did.
Files to Change
actions/setup/js/generate_git_patch.cjs→ replaceformat-patchwith per-commit manifest captureactions/setup/js/push_to_pull_request_branch.cjs→ replacegit am+git pushwith GraphQL replay loopactions/setup/js/safe_outputs_handlers.cjs→ update MCP handler to emit manifest instead of patchactions/setup/sh/generate_git_patch.sh→ update or remove shell versionactions/setup/js/create_pull_request.cjs→ same pattern for PR creationTrade-offs
createCommitOnBranchhas API size limits.Related Issues
gitare unverified; switch to GraphQL for commits #18565 — Commits viagitare unverified; switch to GraphQL for commitsThis proposal addresses both issues: #18801 becomes moot (no more format-patch) and #18565 is resolved (all commits via GraphQL are signed).
Real-World Failure
See elastic/ai-github-actions-playground#590 (comment) — agent attempted to resolve merge conflicts but was blocked because the pipeline cannot handle merge commits or rebased history.