-
Notifications
You must be signed in to change notification settings - Fork 307
Description
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-patchskips merge commits (multiple parents) → empty/incomplete patches- Rebased branches break incremental mode (
origin/<branch>is no longer an ancestor) - Commits pushed via
git pushare 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→ 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 creation
Trade-offs
- File size: Manifest contains full file contents (base64), not diffs. Larger than patches for big changes.
createCommitOnBranchhas 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
- Report errors for git patch types which are not valid #18801 — Report errors for git patch types which are not valid (current format-patch limitations)
- Commits via
gitare unverified; switch to GraphQL for commits #18565 — Commits viagitare unverified; switch to GraphQL for commits
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.