From 255c218bc134d072fab360140519c1e4ac292b43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:25:53 +0000 Subject: [PATCH 1/5] Initial plan From d041ed5881d94f302bfb581c06bee94dba45573e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:36:36 +0000 Subject: [PATCH 2/5] Fall back to unsigned push on genuine rebase conflict in pushSignedCommits Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- ...d-push-genuine-rebase-conflict-fallback.md | 9 +++ actions/setup/js/push_signed_commits.cjs | 20 ++++++- actions/setup/js/push_signed_commits.test.cjs | 57 ++++++++++++++++++- 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-signed-push-genuine-rebase-conflict-fallback.md diff --git a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md new file mode 100644 index 00000000000..94cb2a04e03 --- /dev/null +++ b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md @@ -0,0 +1,9 @@ +--- +"gh-aw": patch +--- + +Fix `pushSignedCommits` unnecessarily failing the whole pull-request-creation operation when the agent's commits genuinely conflict with the base branch during the pre-replay rebase. + +Previously, when the base branch advanced with content that conflicted with the agent's changes (e.g. both edited `CHANGELOG.md`), `pushSignedCommits` aborted the rebase and threw, which caused `create_pull_request` to give up on the pull request entirely and open a fallback issue instead — even though a normal (unsigned) `git push` of the un-rebased commits would have let GitHub create the pull request and simply report it as having conflicts, exactly like any other PR. + +`pushSignedCommits` now falls back to an unsigned `git push` of the original, un-rebased commit range when a genuine (non-recoverable) rebase conflict is detected, unless `allowGitPushFallback: false` is explicitly requested. This lets the pull request still be created in a "has conflicts" state so it can be resolved normally, instead of failing the run and falling back to a GitHub issue. diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index ed06cbca1cd..1662fcf76d0 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -588,9 +588,23 @@ async function pushSignedCommits({ } catch { // Ignore cleanup failures. } - throw new Error( - `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${combinedOutput.trim()}` - ); + const conflictMessage = + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${combinedOutput.trim()}`; + if (allowGitPushFallback === false) { + throw new Error(conflictMessage); + } + // Genuine merge conflict (not a shallow/partial-clone object-fetch issue, and no custom + // resolver handled it): rebasing the commit range onto the current base cannot be done + // automatically, and replaying the stale-base commits through GraphQL would silently + // synthesize file content against the wrong parent. Rather than failing the whole + // operation (which previously forced callers to fall back to opening an issue instead + // of a pull request), push the ORIGINAL un-rebased commits directly via unsigned + // `git push`. GitHub will still create the pull request; it will simply report the + // branch as having conflicts that need to be resolved, the same as any normal PR. + core.warning(`${conflictMessage} Falling back to an unsigned git push of the un-rebased commit(s) so the pull request can still be created (it will show as having merge conflicts with the base branch).`); + const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); + core.info(`pushSignedCommits: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`); + return fallbackSha; } } const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 3a913776884..c7975beb3d0 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -1924,7 +1924,7 @@ describe("push_signed_commits integration tests", () => { }); describe("stale-base and synthesized payload safety", () => { - it("should fail signed replay when rebasing stale commits onto current base conflicts", async () => { + it("should fall back to an unsigned push of the un-rebased commits when rebasing stale commits onto current base conflicts", async () => { // Base branch starts with shared file. fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); execGit(["add", "shared.txt"], { cwd: workDir }); @@ -1936,6 +1936,7 @@ describe("push_signed_commits integration tests", () => { fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); execGit(["add", "shared.txt"], { cwd: workDir }); execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + const localOidBeforePush = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); // Base branch advances with conflicting edit. execGit(["checkout", "main"], { cwd: workDir }); @@ -1949,18 +1950,69 @@ describe("push_signed_commits integration tests", () => { global.exec = makeRealExec(workDir); const githubClient = makeMockGithubClient(); + const result = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "stale-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + // GraphQL is never invoked for this path — the un-rebased commits are pushed directly. + expect(githubClient.graphql).not.toHaveBeenCalled(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("failed to rebase commit range onto current GraphQL parent")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Falling back to an unsigned git push of the un-rebased commit(s)")); + + // The branch was pushed as-is (still based on the stale parent) so GitHub can create the + // pull request and surface the conflict for manual resolution, instead of the whole + // operation failing outright. + expect(result).toBe(localOidBeforePush); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/stale-conflict-branch"], { cwd: workDir }); + const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0]; + expect(remoteOid).toBe(localOidBeforePush); + }); + + it("should still fail (without pushing) when git push fallback is explicitly disabled and rebasing stale commits conflicts", async () => { + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main and edits shared.txt. + execGit(["checkout", "-b", "stale-conflict-no-fallback-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "stale-conflict-no-fallback-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + await expect( pushSignedCommits({ githubClient, owner: "test-owner", repo: "test-repo", - branch: "stale-conflict-branch", + branch: "stale-conflict-no-fallback-branch", baseRef: "origin/main", cwd: workDir, + allowGitPushFallback: false, }) ).rejects.toThrow("failed to rebase commit range onto current GraphQL parent"); expect(githubClient.graphql).not.toHaveBeenCalled(); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/stale-conflict-no-fallback-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); }); it("should recover from a partial-clone object failure by backfilling the exact commit objects and retrying the rebase", async () => { @@ -2086,6 +2138,7 @@ describe("push_signed_commits integration tests", () => { branch: "conflict-no-backfill-branch", baseRef: "origin/main", cwd: workDir, + allowGitPushFallback: false, }) ).rejects.toThrow("failed to rebase commit range onto current GraphQL parent"); From 8819b834e75dfb81149f31d575cfdb68d1b8e9a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:56:39 +0000 Subject: [PATCH 3/5] Surface combined error when unsigned push fallback is rejected (e.g. branch protection) Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- ...d-push-genuine-rebase-conflict-fallback.md | 2 + actions/setup/js/push_signed_commits.cjs | 14 ++++- actions/setup/js/push_signed_commits.test.cjs | 60 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md index 94cb2a04e03..741af997420 100644 --- a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md +++ b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md @@ -7,3 +7,5 @@ Fix `pushSignedCommits` unnecessarily failing the whole pull-request-creation op Previously, when the base branch advanced with content that conflicted with the agent's changes (e.g. both edited `CHANGELOG.md`), `pushSignedCommits` aborted the rebase and threw, which caused `create_pull_request` to give up on the pull request entirely and open a fallback issue instead — even though a normal (unsigned) `git push` of the un-rebased commits would have let GitHub create the pull request and simply report it as having conflicts, exactly like any other PR. `pushSignedCommits` now falls back to an unsigned `git push` of the original, un-rebased commit range when a genuine (non-recoverable) rebase conflict is detected, unless `allowGitPushFallback: false` is explicitly requested. This lets the pull request still be created in a "has conflicts" state so it can be resolved normally, instead of failing the run and falling back to a GitHub issue. + +If the unsigned push itself is rejected (for example, by a branch-protection rule that requires signed commits), `pushSignedCommits` now throws a combined error describing both the original rebase conflict and the push rejection, instead of letting the raw `git push` failure propagate on its own. diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 1662fcf76d0..163213a3502 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -602,9 +602,17 @@ async function pushSignedCommits({ // `git push`. GitHub will still create the pull request; it will simply report the // branch as having conflicts that need to be resolved, the same as any normal PR. core.warning(`${conflictMessage} Falling back to an unsigned git push of the un-rebased commit(s) so the pull request can still be created (it will show as having merge conflicts with the base branch).`); - const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); - core.info(`pushSignedCommits: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`); - return fallbackSha; + try { + const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); + core.info(`pushSignedCommits: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`); + return fallbackSha; + } catch (pushError) { + // The unsigned push itself can be rejected (e.g. by branch protection rules requiring + // signed commits). Surface a clear, combined error instead of letting the raw git-push + // failure — or a misleading "success" — propagate; this preserves the original + // throw-on-conflict behavior for repos where an unsigned push is not a viable fallback. + throw new Error(`${ERR_SYSTEM}: ${conflictMessage} Unsigned git push fallback was also rejected: ${getErrorMessage(pushError)}`, { cause: pushError }); + } } } const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index c7975beb3d0..63957c8d1a6 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -1973,6 +1973,66 @@ describe("push_signed_commits integration tests", () => { expect(remoteOid).toBe(localOidBeforePush); }); + it("should surface a combined error (not swallow it) when the unsigned push fallback is itself rejected, e.g. by branch protection requiring signed commits", async () => { + // Simulate a branch-protection rule that requires signed commits by rejecting every + // push with a pre-receive hook on the bare "remote" repo. + const preReceiveHookPath = path.join(bareDir, "hooks", "pre-receive"); + fs.writeFileSync( + preReceiveHookPath, + "#!/bin/sh\n" + + "while read oldrev newrev refname; do\n" + + ' case "$refname" in\n' + + " refs/heads/protected-conflict-branch)\n" + + ' echo "error: commits must be signed" >&2\n' + + " exit 1\n" + + " ;;\n" + + " esac\n" + + "done\n" + + "exit 0\n" + ); + fs.chmodSync(preReceiveHookPath, 0o755); + + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main and edits shared.txt. + execGit(["checkout", "-b", "protected-conflict-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "protected-conflict-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "protected-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + }) + ).rejects.toThrow(/failed to rebase commit range onto current GraphQL parent[\s\S]*Unsigned git push fallback was also rejected/); + + expect(githubClient.graphql).not.toHaveBeenCalled(); + // The rejected push must not have landed on the "remote". + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/protected-conflict-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); + }); + it("should still fail (without pushing) when git push fallback is explicitly disabled and rebasing stale commits conflicts", async () => { // Base branch starts with shared file. fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); From a5f04984dbfad7875a4ce04cc3e2a69a8044192a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:05:14 +0000 Subject: [PATCH 4/5] Route genuine-conflict fallback through preflight checks; make abort failure fatal Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- ...d-push-genuine-rebase-conflict-fallback.md | 2 + actions/setup/js/push_signed_commits.cjs | 124 +++++++++++++----- actions/setup/js/push_signed_commits.test.cjs | 101 ++++++++++++++ 3 files changed, 193 insertions(+), 34 deletions(-) diff --git a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md index 741af997420..687b31c65db 100644 --- a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md +++ b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md @@ -9,3 +9,5 @@ Previously, when the base branch advanced with content that conflicted with the `pushSignedCommits` now falls back to an unsigned `git push` of the original, un-rebased commit range when a genuine (non-recoverable) rebase conflict is detected, unless `allowGitPushFallback: false` is explicitly requested. This lets the pull request still be created in a "has conflicts" state so it can be resolved normally, instead of failing the run and falling back to a GitHub issue. If the unsigned push itself is rejected (for example, by a branch-protection rule that requires signed commits), `pushSignedCommits` now throws a combined error describing both the original rebase conflict and the push rejection, instead of letting the raw `git push` failure propagate on its own. + +The un-rebased commit range still goes through the same preflight validation as any other unsigned push fallback (merge-commit detection, unsupported file modes such as symlinks/submodules, and file-protection/size policy) before being pushed, and a failed `git rebase --abort` cleanup is now treated as fatal rather than silently ignored, so this fallback never pushes content that should have been refused or operates on a possibly-corrupted worktree. diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 163213a3502..88254fe859d 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -82,6 +82,21 @@ class PushSignedCommitsPolicyViolation extends Error { } } +/** + * Sentinel error class marking a terminal failure of the unsigned git push fallback that is + * attempted after a genuine (non-recoverable) rebase conflict. This must propagate as-is + * without triggering the generic "GraphQL failed, retry with git push" fallback further up — + * that push was already attempted (and rejected), so retrying it again would just repeat the + * same failure (e.g. against a branch-protection rule requiring signed commits). + */ +class PushSignedCommitsUnsignedFallbackFailed extends Error { + /** @param {string} message */ + constructor(message, options) { + super(message, options); + this.name = "PushSignedCommitsUnsignedFallbackFailed"; + } +} + /** * Unescape a C-quoted path returned by `git diff-tree --raw`. * @@ -467,6 +482,14 @@ async function pushSignedCommits({ } let shas = revListEntries.map(entry => entry.sha); + // When a genuine (non-recoverable) rebase conflict is hit below, the original un-rebased + // commit range is pushed unsigned instead of being replayed through GraphQL. Rather than + // returning immediately, that path sets this flag and falls through into the same + // preflight validation (merge commit / unsupported file mode / file-protection policy + // checks) that every other unsigned-push fallback goes through, so genuinely unsupported + // or policy-blocked content still refuses the fallback instead of being pushed as-is. + let unsignedPushFallbackReason; + if (shas.length === 0) { core.info("pushSignedCommits: no new commits to push via GraphQL"); return undefined; @@ -585,49 +608,52 @@ async function pushSignedCommits({ } else { try { await exec.exec("git", ["rebase", "--abort"], { cwd }); - } catch { - // Ignore cleanup failures. + } catch (abortError) { + // If the abort itself fails, HEAD can be left in a detached, partially-rebased or + // still-conflicted state. Continuing from there (falling through to the unsigned push + // fallback, or even the strict throw below) would risk operating on / pushing that + // broken worktree state, so this must be fatal rather than silently ignored. + throw new Error( + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}), and 'git rebase --abort' also failed to restore branch '${branch}' to its original state. ` + + `Root cause: ${combinedOutput.trim()}. Abort failure: ${getErrorMessage(abortError)}`, + { cause: abortError } + ); } - const conflictMessage = - `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${combinedOutput.trim()}`; + const diagnosticMessage = `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}). ` + `Root cause: ${combinedOutput.trim()}`; if (allowGitPushFallback === false) { - throw new Error(conflictMessage); + throw new Error(`${ERR_SYSTEM}: ${diagnosticMessage} Resolve conflicts by rebasing/cherry-picking locally and retry.`); } // Genuine merge conflict (not a shallow/partial-clone object-fetch issue, and no custom // resolver handled it): rebasing the commit range onto the current base cannot be done // automatically, and replaying the stale-base commits through GraphQL would silently // synthesize file content against the wrong parent. Rather than failing the whole // operation (which previously forced callers to fall back to opening an issue instead - // of a pull request), push the ORIGINAL un-rebased commits directly via unsigned - // `git push`. GitHub will still create the pull request; it will simply report the - // branch as having conflicts that need to be resolved, the same as any normal PR. - core.warning(`${conflictMessage} Falling back to an unsigned git push of the un-rebased commit(s) so the pull request can still be created (it will show as having merge conflicts with the base branch).`); - try { - const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); - core.info(`pushSignedCommits: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`); - return fallbackSha; - } catch (pushError) { - // The unsigned push itself can be rejected (e.g. by branch protection rules requiring - // signed commits). Surface a clear, combined error instead of letting the raw git-push - // failure — or a misleading "success" — propagate; this preserves the original - // throw-on-conflict behavior for repos where an unsigned push is not a viable fallback. - throw new Error(`${ERR_SYSTEM}: ${conflictMessage} Unsigned git push fallback was also rejected: ${getErrorMessage(pushError)}`, { cause: pushError }); - } + // of a pull request), fall through to push the ORIGINAL un-rebased commits directly via + // unsigned `git push` once they pass the same preflight validation (merge commit / + // unsupported file mode / file-protection policy) as any other unsigned push fallback. + // GitHub will still create the pull request; it will simply report the branch as having + // conflicts that need to be resolved, the same as any normal PR. + // `rebase --abort` (above) already restored HEAD and the branch to their original, + // un-rebased state, so revListEntries/shas (computed before the rebase attempt) still + // describe exactly what will be pushed — no need to recompute them. + unsignedPushFallbackReason = diagnosticMessage; } } - const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); - revListEntries = rebasedRevListOut - .trim() - .split("\n") - .filter(Boolean) - .map(line => { - const fields = line.split(" "); - return { line, fields, sha: fields[0] }; - }); - shas = revListEntries.map(entry => entry.sha); - if (shas.length === 0) { - core.info("pushSignedCommits: no new commits to replay after rebase"); - return undefined; + if (!unsignedPushFallbackReason) { + const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); + revListEntries = rebasedRevListOut + .trim() + .split("\n") + .filter(Boolean) + .map(line => { + const fields = line.split(" "); + return { line, fields, sha: fields[0] }; + }); + shas = revListEntries.map(entry => entry.sha); + if (shas.length === 0) { + core.info("pushSignedCommits: no new commits to replay after rebase"); + return undefined; + } } } @@ -761,6 +787,32 @@ async function pushSignedCommits({ deletionsMap.set(sha, deletions); } + // Enforce file-protection/size policy for every commit up front — before choosing between + // an unsigned push fallback and the GraphQL replay below — so a validationConfig-blocked + // file always refuses the unsigned push fallback, not just the GraphQL path. + for (const sha of shas) { + validateSynthesizedFileChanges(additionsMap.get(sha) || [], deletionsMap.get(sha) || [], validationConfig); + } + + if (unsignedPushFallbackReason) { + // All commits in the original, un-rebased range passed the same merge-commit, + // unsupported-file-mode, and file-protection-policy checks as the GraphQL replay path + // above; push them directly via unsigned `git push` instead of replaying through GraphQL + // (which would synthesize file content against the wrong parent). + core.warning(`${unsignedPushFallbackReason} Falling back to an unsigned git push of the un-rebased commit(s) so the pull request can still be created (it will show as having merge conflicts with the base branch).`); + try { + const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken }); + core.info(`pushSignedCommits: unsigned git push fallback (unresolved rebase conflict) completed, using pushed SHA ${fallbackSha}`); + return fallbackSha; + } catch (pushError) { + // The unsigned push itself can be rejected (e.g. by branch protection rules requiring + // signed commits). Surface a clear, combined error instead of letting the raw git-push + // failure — or a misleading "success" — propagate; this preserves the original + // throw-on-conflict behavior for repos where an unsigned push is not a viable fallback. + throw new PushSignedCommitsUnsignedFallbackFailed(`${ERR_SYSTEM}: ${unsignedPushFallbackReason} Unsigned git push fallback was also rejected: ${getErrorMessage(pushError)}`, { cause: pushError }); + } + } + // All commits passed the mode checks. Replay via GraphQL. /** @type {string | undefined} */ let lastOid; @@ -836,7 +888,6 @@ async function pushSignedCommits({ const additions = additionsMap.get(sha) || []; const deletions = deletionsMap.get(sha) || []; - validateSynthesizedFileChanges(additions, deletions, validationConfig); core.info(`pushSignedCommits: file changes: ${additions.length} addition(s), ${deletions.length} deletion(s)`); /** @type {any} */ @@ -864,6 +915,11 @@ async function pushSignedCommits({ core.info(`pushSignedCommits: all ${shas.length} commit(s) pushed as signed commits`); return lastOid ?? shas[shas.length - 1]; } catch (err) { + if (err instanceof PushSignedCommitsUnsignedFallbackFailed) { + // The unsigned push fallback for a genuine rebase conflict was already attempted (and + // rejected) above; re-throw as-is instead of retrying the same push again below. + throw err; + } if (err instanceof PushSignedCommitsUnsupportedShape) { throw new Error( `${ERR_VALIDATION}: pushSignedCommits: refusing unsigned push for branch '${branch}': ${stripLeadingErrorCode(getErrorMessage(err))}. ` + diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 63957c8d1a6..a3cf190a8a7 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -2033,6 +2033,53 @@ describe("push_signed_commits integration tests", () => { expect(lsRemote.stdout.trim()).toBe(""); }); + it("should refuse the unsigned push fallback (not push) when the un-rebased conflicting range violates a file-protection policy", async () => { + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main, edits shared.txt AND touches a protected file. + execGit(["checkout", "-b", "policy-conflict-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + fs.writeFileSync(path.join(workDir, "CODEOWNERS"), "* @octocat\n"); + execGit(["add", "shared.txt", "CODEOWNERS"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared and touch CODEOWNERS"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "policy-conflict-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "policy-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + validationConfig: { + protected_files: ["CODEOWNERS"], + protected_files_policy: "blocked", + }, + }) + ).rejects.toThrow("Signed-commit payload violates file-protection policy"); + + // The blocked commits must not be pushed unsigned just because the rebase conflicted. + expect(githubClient.graphql).not.toHaveBeenCalled(); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/policy-conflict-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); + }); + it("should still fail (without pushing) when git push fallback is explicitly disabled and rebasing stale commits conflicts", async () => { // Base branch starts with shared file. fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); @@ -2075,6 +2122,60 @@ describe("push_signed_commits integration tests", () => { expect(lsRemote.stdout.trim()).toBe(""); }); + it("should fail fatally (without attempting any push) when 'git rebase --abort' itself fails after a genuine conflict", async () => { + // Base branch starts with shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges from old main and edits shared.txt. + execGit(["checkout", "-b", "abort-failure-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with conflicting edit. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "abort-failure-branch"], { cwd: workDir }); + + // Wrap the real exec so that only "git rebase --abort" is forced to fail, simulating a + // broken worktree (e.g. a corrupted rebase-merge state) that prevents cleanup. + const realExec = makeRealExec(workDir); + global.exec = { + ...realExec, + exec: async (program, args, opts) => { + if (program === "git" && args[0] === "rebase" && args[1] === "--abort") { + throw new Error("simulated: could not restore original branch state"); + } + return realExec.exec(program, args, opts); + }, + }; + const githubClient = makeMockGithubClient(); + + await expect( + pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "abort-failure-branch", + baseRef: "origin/main", + cwd: workDir, + }) + ).rejects.toThrow(/git rebase --abort.*also failed/); + + // Nothing must have been pushed — the abort failure must short-circuit before any + // unsigned-push fallback is attempted. + expect(githubClient.graphql).not.toHaveBeenCalled(); + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/abort-failure-branch"], { cwd: workDir }); + expect(lsRemote.stdout.trim()).toBe(""); + }); + it("should recover from a partial-clone object failure by backfilling the exact commit objects and retrying the rebase", async () => { // Base branch starts with a file. fs.writeFileSync(path.join(workDir, "base.txt"), "base\n"); From 8ed42f935629079dabede0bd99c2ed9290faebea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:44:39 +0000 Subject: [PATCH 5/5] Route post-backfill genuine conflict through the same unsigned-push fallback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- ...d-push-genuine-rebase-conflict-fallback.md | 2 + actions/setup/js/push_signed_commits.cjs | 35 +++++++-- actions/setup/js/push_signed_commits.test.cjs | 72 +++++++++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md index 687b31c65db..d624792f1f5 100644 --- a/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md +++ b/.changeset/fix-signed-push-genuine-rebase-conflict-fallback.md @@ -11,3 +11,5 @@ Previously, when the base branch advanced with content that conflicted with the If the unsigned push itself is rejected (for example, by a branch-protection rule that requires signed commits), `pushSignedCommits` now throws a combined error describing both the original rebase conflict and the push rejection, instead of letting the raw `git push` failure propagate on its own. The un-rebased commit range still goes through the same preflight validation as any other unsigned push fallback (merge-commit detection, unsupported file modes such as symlinks/submodules, and file-protection/size policy) before being pushed, and a failed `git rebase --abort` cleanup is now treated as fatal rather than silently ignored, so this fallback never pushes content that should have been refused or operates on a possibly-corrupted worktree. + +This same fallback (including the preflight checks and fatal-abort handling) now also applies when a genuine content conflict is only revealed *after* a shallow/partial-clone object backfill succeeds and the retried rebase still fails — previously that case unconditionally threw instead of falling back, even though it is the same class of "genuine conflict" as the non-backfill case. diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 88254fe859d..b615664267e 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -587,16 +587,37 @@ async function pushSignedCommits({ if (recovered) { rebaseResult = await runRebase(); if (rebaseResult.exitCode !== 0) { + const retryOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`; try { await exec.exec("git", ["rebase", "--abort"], { cwd }); - } catch { - // Ignore cleanup failures. + } catch (abortError) { + throw new Error( + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects, ` + + `and 'git rebase --abort' also failed to restore branch '${branch}' to its original state. ` + + `Root cause: ${retryOutput.trim()}. Abort failure: ${getErrorMessage(abortError)}`, + { cause: abortError } + ); } - const retryOutput = `${rebaseResult.stdout || ""}\n${rebaseResult.stderr || ""}`; - throw new Error( - `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + - `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${retryOutput.trim()}` - ); + if (isPartialCloneObjectFailure(retryOutput)) { + // Still a missing-object failure even after the targeted backfill — the shallow/ + // partial clone genuinely cannot supply what this rebase needs, so there is no + // valid replay range to fall back to unsigned; this remains fatal. + throw new Error( + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + + `Resolve conflicts by rebasing/cherry-picking locally and retry. Root cause: ${retryOutput.trim()}` + ); + } + // The backfill succeeded (the objects needed to attempt the rebase are now present), + // but retrying the rebase still failed — this is a genuine content conflict that was + // only revealed once the missing objects were available, not a partial-clone object + // issue. Route it through the same unsigned-push fallback as any other genuine + // conflict below, instead of unconditionally throwing. + const diagnosticMessage = + `${ERR_SYSTEM}: pushSignedCommits: failed to rebase commit range onto current GraphQL parent (${firstGraphqlParentOid}) even after backfilling the required commit objects. ` + `Root cause: ${retryOutput.trim()}`; + if (allowGitPushFallback === false) { + throw new Error(`${ERR_SYSTEM}: ${diagnosticMessage} Resolve conflicts by rebasing/cherry-picking locally and retry.`); + } + unsignedPushFallbackReason = diagnosticMessage; } } else { throw new Error( diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index a3cf190a8a7..0bf3a33bbbb 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -2308,6 +2308,78 @@ describe("push_signed_commits integration tests", () => { expect(githubClient.graphql).not.toHaveBeenCalled(); }); + it("should fall back to an unsigned push when a genuine merge conflict is only revealed after a successful object backfill", async () => { + // Base branch starts with a shared file. + fs.writeFileSync(path.join(workDir, "shared.txt"), "base\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add shared file"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + // Agent branch diverges and edits the shared file. + execGit(["checkout", "-b", "backfill-then-conflict-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "agent change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Agent edit shared"], { cwd: workDir }); + + // Base branch advances with a conflicting edit to the same file. + execGit(["checkout", "main"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "shared.txt"), "upstream change\n"); + execGit(["add", "shared.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Upstream edit shared"], { cwd: workDir }); + execGit(["push", "origin", "main"], { cwd: workDir }); + + execGit(["checkout", "backfill-then-conflict-branch"], { cwd: workDir }); + + // Simulate the FIRST rebase attempt failing due to a promisor object-fetch + // failure (recoverable), the backfill "succeeding" (mocked), and then the + // SECOND (real) rebase attempt hitting the genuine content conflict. + const realExec = makeRealExec(workDir); + let rebaseAttempts = 0; + global.exec = { + getExecOutput: async (program, args, opts = {}) => { + if (program === "git" && args[0] === "rebase" && args[1] === "--onto") { + rebaseAttempts++; + if (rebaseAttempts === 1) { + return { + exitCode: 128, + stdout: "", + stderr: "fatal: remote error: upload-pack: not our ref 0035eb55fe03ab52d8b95e7fcfaee53548b5e8d6\nfatal: could not fetch 4f0af08119278bacff5772a1ddf987d4b4045be8 from promisor remote\n", + }; + } + } + if (program === "git" && args[0] === "fetch" && args.includes("--no-filter")) { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return realExec.getExecOutput(program, args, opts); + }, + exec: realExec.exec, + }; + + const githubClient = makeMockGithubClient(); + + const result = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "backfill-then-conflict-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + // The rebase was attempted twice (initial promisor failure + post-backfill + // retry, which then hits the real content conflict). + expect(rebaseAttempts).toBe(2); + // The genuine post-backfill conflict falls back to an unsigned push instead + // of throwing, and no GraphQL replay is attempted. + expect(githubClient.graphql).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("even after backfilling the required commit objects")); + + // The original (un-rebased) agent commit landed on the remote as-is. + const remoteLog = execGit(["log", "--format=%s", "backfill-then-conflict-branch"], { cwd: bareDir }).stdout.trim().split("\n"); + expect(remoteLog).toContain("Agent edit shared"); + }); + it("should merge state.json conflicts when a custom resolver is provided", async () => { const concurrentDir = fs.mkdtempSync(path.join(os.tmpdir(), "push-signed-concurrent-")); try {