Skip to content

Commit 84d55b6

Browse files
authored
Handle deleted PR head branches in push_to_pull_request_branch before fetch and after push failures (#26705)
1 parent 3b82462 commit 84d55b6

File tree

2 files changed

+133
-1
lines changed

2 files changed

+133
-1
lines changed

actions/setup/js/push_to_pull_request_branch.cjs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,30 @@ async function main(config = {}) {
455455
// Switch to or create the target branch
456456
core.info(`Switching to branch: ${branchName}`);
457457

458+
// Detect missing/deleted branches early and return a clear error.
459+
// This avoids an opaque git fetch exit code when the PR branch was deleted.
460+
{
461+
const lsRemoteResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], {
462+
env: { ...process.env, ...gitAuthEnv },
463+
ignoreReturnCode: true,
464+
});
465+
466+
if (lsRemoteResult.exitCode === 2) {
467+
return {
468+
success: false,
469+
error: `Branch ${branchName} no longer exists on origin (it may have been deleted), can't push to it.`,
470+
};
471+
}
472+
473+
if (lsRemoteResult.exitCode !== 0) {
474+
const stderr = (lsRemoteResult.stderr || "").trim();
475+
return {
476+
success: false,
477+
error: `Failed to verify branch ${branchName} exists on origin: ${stderr || `git ls-remote exited with code ${lsRemoteResult.exitCode}`}`,
478+
};
479+
}
480+
}
481+
458482
// Fetch the specific target branch from origin
459483
// Use GIT_CONFIG_* env vars for auth because .git/config credentials are
460484
// cleaned by clean_git_credentials.sh before the agent runs.
@@ -684,9 +708,27 @@ async function main(config = {}) {
684708
core.error(`Failed to push changes: ${pushErrorMessage}`);
685709
const nonFastForwardPatterns = ["non-fast-forward", "rejected", "fetch first", "Updates were rejected"];
686710
const isNonFastForward = nonFastForwardPatterns.some(pattern => pushErrorMessage.includes(pattern));
687-
const userMessage = isNonFastForward
711+
let userMessage = isNonFastForward
688712
? "Failed to push changes: remote PR branch changed while the workflow was running (non-fast-forward). Re-run the workflow on the latest PR branch state."
689713
: `Failed to push changes: ${pushErrorMessage}`;
714+
715+
// Diagnose common race where branch was deleted after preflight checks.
716+
try {
717+
const lsRemoteAfterPushResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], {
718+
env: { ...process.env, ...gitAuthEnv },
719+
ignoreReturnCode: true,
720+
});
721+
722+
if (lsRemoteAfterPushResult.exitCode === 2) {
723+
userMessage = "Failed to push changes: remote PR branch appears to have been deleted while the workflow was running.";
724+
} else if (lsRemoteAfterPushResult.exitCode !== 0) {
725+
const remoteCheckError = (lsRemoteAfterPushResult.stderr || "").trim();
726+
core.warning(`Push failed and branch existence re-check also failed for ${branchName}: ${remoteCheckError || `git ls-remote exited with code ${lsRemoteAfterPushResult.exitCode}`}`);
727+
}
728+
} catch (diagnosisError) {
729+
core.warning(`Push failed and branch existence re-check errored for ${branchName}: ${getErrorMessage(diagnosisError)}`);
730+
}
731+
690732
return { success: false, error_type: "push_failed", error: userMessage };
691733
}
692734

actions/setup/js/push_to_pull_request_branch.test.cjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,35 @@ index 0000000..abc1234
631631
expect(result.commit_url).toContain("test-owner/test-repo/commit/");
632632
});
633633

634+
it("should detect deleted branch before fetch", async () => {
635+
const patchPath = createPatchFile();
636+
637+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 2, stdout: "", stderr: "fatal: couldn't find remote ref feature-branch" });
638+
639+
const module = await loadModule();
640+
const handler = await module.main({});
641+
const result = await handler({ patch_path: patchPath }, {});
642+
643+
expect(result.success).toBe(false);
644+
expect(result.error).toContain("no longer exists on origin");
645+
expect(mockExec.exec).not.toHaveBeenCalled();
646+
});
647+
648+
it("should fail with diagnostic error when branch existence check fails for other reasons", async () => {
649+
const patchPath = createPatchFile();
650+
651+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 128, stdout: "", stderr: "fatal: Authentication failed" });
652+
653+
const module = await loadModule();
654+
const handler = await module.main({});
655+
const result = await handler({ patch_path: patchPath }, {});
656+
657+
expect(result.success).toBe(false);
658+
expect(result.error).toContain("Failed to verify branch");
659+
expect(result.error).toContain("Authentication failed");
660+
expect(mockExec.exec).not.toHaveBeenCalled();
661+
});
662+
634663
it("should handle git fetch failure", async () => {
635664
const patchPath = createPatchFile();
636665

@@ -740,6 +769,67 @@ index 0000000..abc1234
740769
expect(result.success).toBe(false);
741770
});
742771

772+
it("should diagnose deleted branch when push fails", async () => {
773+
const patchPath = createPatchFile();
774+
775+
// Set up successful operations until push
776+
mockExec.exec.mockResolvedValueOnce(0); // fetch
777+
mockExec.exec.mockResolvedValueOnce(0); // rev-parse
778+
mockExec.exec.mockResolvedValueOnce(0); // checkout
779+
780+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)
781+
782+
mockExec.exec.mockResolvedValueOnce(0); // git am
783+
784+
// pushSignedCommits + post-failure diagnosis responses
785+
const originalGetExecOutput = mockExec.getExecOutput;
786+
let exitCodeLsRemoteCallCount = 0;
787+
mockExec.getExecOutput = vi.fn().mockImplementation(async (cmd, args) => {
788+
const argList = Array.isArray(args) ? args : [];
789+
if (argList[0] === "rev-parse" && argList[1] === "HEAD") {
790+
return { exitCode: 0, stdout: "before-sha\n", stderr: "" };
791+
}
792+
if (argList[0] === "rev-list") {
793+
return { exitCode: 0, stdout: "abc123\n", stderr: "" };
794+
}
795+
if (argList[0] === "diff-tree") {
796+
return { exitCode: 0, stdout: "", stderr: "" };
797+
}
798+
if (argList[0] === "ls-remote" && argList[1] === "--exit-code") {
799+
exitCodeLsRemoteCallCount += 1;
800+
if (exitCodeLsRemoteCallCount === 1) {
801+
// Initial preflight check before fetch
802+
return { exitCode: 0, stdout: "remote-oid\trefs/heads/feature-branch\n", stderr: "" };
803+
}
804+
// Post-push diagnosis call from push_to_pull_request_branch catch block
805+
return { exitCode: 2, stdout: "", stderr: "fatal: couldn't find remote ref feature-branch" };
806+
}
807+
if (argList[0] === "ls-remote") {
808+
return { exitCode: 0, stdout: "remote-oid\trefs/heads/feature-branch\n", stderr: "" };
809+
}
810+
if (argList[0] === "log") {
811+
return { exitCode: 0, stdout: "Test commit\n", stderr: "" };
812+
}
813+
if (argList[0] === "diff" && argList[1] === "--name-status") {
814+
return { exitCode: 0, stdout: "", stderr: "" };
815+
}
816+
return originalGetExecOutput(cmd, args);
817+
});
818+
819+
// GraphQL call fails, triggering fallback to git push
820+
mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
821+
// Fallback git push fails
822+
mockExec.exec.mockRejectedValueOnce(new Error("remote: Internal Server Error"));
823+
824+
const module = await loadModule();
825+
const handler = await module.main({});
826+
const result = await handler({ patch_path: patchPath }, {});
827+
828+
expect(result.success).toBe(false);
829+
expect(result.error_type).toBe("push_failed");
830+
expect(result.error).toContain("appears to have been deleted");
831+
});
832+
743833
it("should detect force-pushed branch via ref mismatch", async () => {
744834
const patchPath = createPatchFile();
745835

0 commit comments

Comments
 (0)