Skip to content

Commit 4f668af

Browse files
authored
Add fallback PR flow for diverged push-to-pull-request-branch and make it opt-out (#27220)
1 parent 47d8f4d commit 4f668af

15 files changed

Lines changed: 436 additions & 24 deletions

actions/setup/js/push_to_pull_request_branch.cjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async function main(config = {}) {
5656
const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
5757
const ifNoChanges = config.if_no_changes || "warn";
5858
const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true;
59+
const fallbackAsPullRequest = config.fallback_as_pull_request !== false;
5960
const commitTitleSuffix = config.commit_title_suffix || "";
6061
const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024;
6162
const maxCount = config.max || 0; // 0 means no limit
@@ -91,6 +92,7 @@ async function main(config = {}) {
9192
}
9293
core.info(`If no changes: ${ifNoChanges}`);
9394
core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`);
95+
core.info(`Fallback as pull request: ${fallbackAsPullRequest}`);
9496
if (commitTitleSuffix) {
9597
core.info(`Commit title suffix: ${commitTitleSuffix}`);
9698
}
@@ -770,6 +772,58 @@ async function main(config = {}) {
770772
core.warning(`Push failed and branch existence re-check errored for ${branchName}: ${getErrorMessage(diagnosisError)}`);
771773
}
772774

775+
// Fallback path for diverged branches: create a new pull request so changes
776+
// can still be reviewed and merged into the original PR branch.
777+
if (isNonFastForward && fallbackAsPullRequest) {
778+
const fallbackBranchName = normalizeBranchName(`${branchName}-fallback`, String(Date.now()));
779+
core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`);
780+
try {
781+
await exec.exec("git", ["checkout", "-b", fallbackBranchName]);
782+
await exec.exec("git", ["push", "origin", fallbackBranchName], {
783+
env: { ...process.env, ...gitAuthEnv },
784+
});
785+
786+
const fallbackBody = [
787+
"> [!NOTE]",
788+
"> Direct push to the original pull request branch failed because the branch diverged (non-fast-forward).",
789+
`> Original PR branch: \`${branchName}\``,
790+
"",
791+
`This fallback PR contains the prepared changes for PR #${pullNumber}.`,
792+
"Merge this fallback PR into the original PR branch to apply them.",
793+
"",
794+
`Workflow run: ${buildWorkflowRunUrl(context, context.repo)}`,
795+
].join("\n");
796+
797+
const { data: fallbackPR } = await githubClient.rest.pulls.create({
798+
owner: repoParts.owner,
799+
repo: repoParts.repo,
800+
title: `[fallback] ${prTitle || `Changes for #${pullNumber}`}`,
801+
body: fallbackBody,
802+
head: fallbackBranchName,
803+
base: branchName,
804+
});
805+
806+
core.info(`Created fallback pull request #${fallbackPR.number}: ${fallbackPR.html_url}`);
807+
await updateActivationComment(github, context, core, fallbackPR.html_url, fallbackPR.number, "pull_request");
808+
809+
return {
810+
success: true,
811+
fallback_used: true,
812+
fallback_type: "pull_request",
813+
pull_request_number: fallbackPR.number,
814+
pull_request_url: fallbackPR.html_url,
815+
branch_name: fallbackBranchName,
816+
repo: itemRepo,
817+
number: fallbackPR.number,
818+
url: fallbackPR.html_url,
819+
};
820+
} catch (fallbackError) {
821+
const fallbackErrorMessage = getErrorMessage(fallbackError);
822+
core.error(`Failed to create fallback pull request: ${fallbackErrorMessage}`);
823+
userMessage = `${userMessage} Fallback pull request creation also failed: ${fallbackErrorMessage}`;
824+
}
825+
}
826+
773827
return { success: false, error_type: "push_failed", error: userMessage };
774828
}
775829

actions/setup/js/push_to_pull_request_branch.test.cjs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ describe("push_to_pull_request_branch.cjs", () => {
144144
labels: [],
145145
},
146146
}),
147+
create: vi.fn().mockResolvedValue({
148+
data: {
149+
number: 999,
150+
html_url: "https://github.com/test-owner/test-repo/pull/999",
151+
},
152+
}),
147153
},
148154
repos: {
149155
get: vi.fn().mockResolvedValue({
@@ -767,7 +773,7 @@ index 0000000..abc1234
767773
expect(mockCore.info).toHaveBeenCalledWith("Investigating patch failure...");
768774
});
769775

770-
it("should handle git push rejection (concurrent changes)", async () => {
776+
it("should create fallback pull request on non-fast-forward push rejection by default", async () => {
771777
const patchPath = createPatchFile();
772778

773779
// Set up successful operations until push
@@ -798,8 +804,40 @@ index 0000000..abc1234
798804
const handler = await module.main({});
799805
const result = await handler({ patch_path: patchPath }, {});
800806

801-
// The error happens during push
807+
expect(result.success).toBe(true);
808+
expect(result.fallback_used).toBe(true);
809+
expect(result.fallback_type).toBe("pull_request");
810+
expect(result.pull_request_number).toBe(999);
811+
expect(mockGithub.rest.pulls.create).toHaveBeenCalled();
812+
});
813+
814+
it("should not create fallback pull request when fallback-as-pull-request is disabled", async () => {
815+
const patchPath = createPatchFile();
816+
817+
mockExec.exec.mockResolvedValueOnce(0); // fetch
818+
mockExec.exec.mockResolvedValueOnce(0); // rev-parse
819+
mockExec.exec.mockResolvedValueOnce(0); // checkout
820+
821+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)
822+
823+
mockExec.exec.mockResolvedValueOnce(0); // git am
824+
825+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" }); // git rev-list
826+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "remote-oid\trefs/heads/feature-branch\n", stderr: "" }); // git ls-remote
827+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "Test commit\n", stderr: "" }); // git log -1
828+
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // git diff --name-status
829+
830+
mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
831+
mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)"));
832+
833+
const module = await loadModule();
834+
const handler = await module.main({ fallback_as_pull_request: false });
835+
const result = await handler({ patch_path: patchPath }, {});
836+
802837
expect(result.success).toBe(false);
838+
expect(result.error_type).toBe("push_failed");
839+
expect(result.error).toContain("non-fast-forward");
840+
expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled();
803841
});
804842

805843
it("should diagnose deleted branch when push fails", async () => {

actions/setup/js/safe_output_handler_manager.cjs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,10 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
360360
/** @type {Array<{type: string, error: string}>} */
361361
const codePushFailures = [];
362362

363-
// Track when a code-push operation falls back to creating a review issue instead.
363+
// Track when a code-push operation falls back to creating an issue or pull request instead.
364364
// When set, subsequent add_comment messages will receive a correction note prepended
365-
// to their body so the posted comment accurately reflects the actual outcome.
366-
/** @type {{type: string, issueNumber: number, issueUrl: string}|null} */
365+
// to their body so the posted comment accurately reflects the actual fallback target.
366+
/** @type {{type: string, fallbackTargetType: "issue" | "pull_request", number: number, url: string}|null} */
367367
let codePushFallbackInfo = null;
368368

369369
// Load custom safe output job types (from GH_AW_SAFE_OUTPUT_JOBS env var)
@@ -481,9 +481,12 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
481481
// If a previous code-push operation fell back to a review issue, prepend a correction note
482482
// so the posted comment accurately reflects the outcome.
483483
if (codePushFallbackInfo) {
484-
const fallbackNote = `\n\n---\n> [!NOTE]\n> The pull request was not created — a fallback review issue was created instead due to protected file changes: [#${codePushFallbackInfo.issueNumber}](${codePushFallbackInfo.issueUrl})\n\n`;
484+
const fallbackNote =
485+
codePushFallbackInfo.fallbackTargetType === "pull_request"
486+
? `\n\n---\n> [!NOTE]\n> Direct push to the original pull request branch was not possible (diverged/non-fast-forward). A fallback pull request was created instead: [#${codePushFallbackInfo.number}](${codePushFallbackInfo.url})\n\n`
487+
: `\n\n---\n> [!NOTE]\n> The pull request was not created — a fallback review issue was created instead due to protected file changes: [#${codePushFallbackInfo.number}](${codePushFallbackInfo.url})\n\n`;
485488
effectiveMessage = { ...effectiveMessage, body: fallbackNote + (effectiveMessage.body || "") };
486-
core.info(`Prepending fallback correction note to add_comment body (fallback issue: #${codePushFallbackInfo.issueNumber})`);
489+
core.info(`Prepending fallback correction note to add_comment body (fallback ${codePushFallbackInfo.fallbackTargetType}: #${codePushFallbackInfo.number})`);
487490
}
488491
// If a previous code-push operation failed outright (e.g. patch application error),
489492
// prepend a failure warning so the status comment accurately reflects that the
@@ -585,11 +588,26 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
585588
}
586589
}
587590

588-
// Track when a code-push operation falls back to a review issue so subsequent
591+
// Track when a code-push operation falls back to an issue or pull request so subsequent
589592
// add_comment messages can include a correction note.
590-
if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true && result.issue_number != null && result.issue_url) {
591-
codePushFallbackInfo = { type: messageType, issueNumber: result.issue_number, issueUrl: result.issue_url };
592-
core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`);
593+
if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true) {
594+
if (result.issue_number != null && result.issue_url) {
595+
codePushFallbackInfo = {
596+
type: messageType,
597+
fallbackTargetType: "issue",
598+
number: result.issue_number,
599+
url: result.issue_url,
600+
};
601+
core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`);
602+
} else if (result.pull_request_number != null && result.pull_request_url) {
603+
codePushFallbackInfo = {
604+
type: messageType,
605+
fallbackTargetType: "pull_request",
606+
number: result.pull_request_number,
607+
url: result.pull_request_url,
608+
};
609+
core.info(`Code push '${messageType}' fell back to pull request #${result.pull_request_number} — add_comment messages will be annotated`);
610+
}
593611
}
594612

595613
// Check if this output was created with unresolved temporary IDs

actions/setup/js/safe_output_handler_manager.test.cjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,34 @@ describe("Safe Output Handler Manager", () => {
13331333
expect(calledMessage.body).toContain("#7");
13341334
});
13351335

1336+
it("should prepend fallback note to add_comment body when push_to_pull_request_branch falls back to pull request", async () => {
1337+
const messages = [
1338+
{ type: "push_to_pull_request_branch", branch: "fix-branch" },
1339+
{ type: "add_comment", body: "Changes pushed." },
1340+
];
1341+
1342+
const pushHandler = vi.fn().mockResolvedValue({
1343+
success: true,
1344+
fallback_used: true,
1345+
fallback_type: "pull_request",
1346+
pull_request_number: 71,
1347+
pull_request_url: "https://github.com/owner/repo/pull/71",
1348+
});
1349+
const commentHandler = vi.fn().mockResolvedValue([{ _tracking: null }]);
1350+
1351+
const handlers = new Map([
1352+
["push_to_pull_request_branch", pushHandler],
1353+
["add_comment", commentHandler],
1354+
]);
1355+
1356+
await processMessages(handlers, messages);
1357+
1358+
const calledMessage = commentHandler.mock.calls[0][0];
1359+
expect(calledMessage.body).toContain("Direct push to the original pull request branch was not possible");
1360+
expect(calledMessage.body).toContain("#71");
1361+
expect(calledMessage.body).toContain("https://github.com/owner/repo/pull/71");
1362+
});
1363+
13361364
it("should NOT prepend fallback note when create_pull_request succeeds normally", async () => {
13371365
const messages = [
13381366
{ type: "create_pull_request", title: "My Fix PR" },

actions/setup/js/safe_output_summary.cjs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,28 +30,41 @@ function generateSafeOutputSummary(options) {
3030
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
3131
.join(" ");
3232

33-
// Detect fallback-to-issue outcome for code-push types
33+
// Detect fallback outcomes for code-push types.
34+
// Prefer explicit fallback_type when available; infer only for backward compatibility.
3435
const isFallback = success && result && result.fallback_used === true;
36+
const inferredFallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue";
37+
const fallbackType = isFallback && result?.fallback_type ? result.fallback_type : inferredFallbackType;
3538

3639
// Choose emoji and status based on success and fallback
3740
const emoji = isFallback ? "⚠️" : success ? "✅" : "❌";
38-
const status = isFallback ? "Fallback Issue Created" : success ? "Success" : "Failed";
41+
const status = isFallback ? (fallbackType === "pull_request" ? "Fallback Pull Request Created" : "Fallback Issue Created") : success ? "Success" : "Failed";
3942

4043
// Start building the summary
4144
let summary = `<details>\n<summary>${emoji} ${displayType} - ${status} (Message ${messageIndex})</summary>\n\n`;
4245

4346
// Add message details
44-
const sectionTitle = isFallback ? `### ${displayType} — Fallback Issue\n\n` : `### ${displayType}\n\n`;
47+
const sectionTitle = isFallback ? `### ${displayType}${fallbackType === "pull_request" ? "Fallback Pull Request" : "Fallback Issue"}\n\n` : `### ${displayType}\n\n`;
4548
summary += sectionTitle;
4649

4750
if (isFallback) {
48-
// Explain why the fallback occurred and show the created issue
49-
summary += `> ℹ️ Pull request creation was blocked due to protected file changes. A review issue was created instead.\n\n`;
50-
if (result.issue_url) {
51-
summary += `**Fallback Issue:** ${result.issue_url}\n\n`;
52-
}
53-
if (result.issue_number != null && result.repo) {
54-
summary += `**Location:** ${result.repo}#${result.issue_number}\n\n`;
51+
// Explain why the fallback occurred and show the created fallback target
52+
if (fallbackType === "pull_request") {
53+
summary += `> ℹ️ Direct push to the original pull request branch was not possible (diverged/non-fast-forward). A fallback pull request was created instead.\n\n`;
54+
if (result.pull_request_url) {
55+
summary += `**Fallback Pull Request:** ${result.pull_request_url}\n\n`;
56+
}
57+
if (result.pull_request_number != null && result.repo) {
58+
summary += `**Location:** ${result.repo}#${result.pull_request_number}\n\n`;
59+
}
60+
} else {
61+
summary += `> ℹ️ Pull request creation was blocked due to protected file changes. A review issue was created instead.\n\n`;
62+
if (result.issue_url) {
63+
summary += `**Fallback Issue:** ${result.issue_url}\n\n`;
64+
}
65+
if (result.issue_number != null && result.repo) {
66+
summary += `**Location:** ${result.repo}#${result.issue_number}\n\n`;
67+
}
5568
}
5669
if (result.branch_name) {
5770
summary += `**Branch:** \`${result.branch_name}\`\n\n`;

actions/setup/js/safe_output_summary.test.cjs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,59 @@ describe("safe_output_summary", () => {
360360
expect(summary).not.toContain("⚠️");
361361
expect(summary).not.toContain("Fallback");
362362
});
363+
364+
it("should show fallback pull request status when push_to_pull_request_branch falls back to pull request", () => {
365+
const options = {
366+
type: "push_to_pull_request_branch",
367+
messageIndex: 3,
368+
success: true,
369+
result: {
370+
fallback_used: true,
371+
fallback_type: "pull_request",
372+
pull_request_number: 71,
373+
pull_request_url: "https://github.com/owner/repo/pull/71",
374+
repo: "owner/repo",
375+
},
376+
message: {
377+
body: "Pushing to PR branch.",
378+
},
379+
};
380+
381+
const summary = generateSafeOutputSummary(options);
382+
383+
expect(summary).toContain("⚠️");
384+
expect(summary).toContain("Fallback Pull Request Created");
385+
expect(summary).toContain("https://github.com/owner/repo/pull/71");
386+
expect(summary).toContain("owner/repo#71");
387+
expect(summary).toContain("non-fast-forward");
388+
});
389+
390+
it("should prefer explicit fallback_type over inferred shape for backward compatibility", () => {
391+
const options = {
392+
type: "push_to_pull_request_branch",
393+
messageIndex: 4,
394+
success: true,
395+
result: {
396+
fallback_used: true,
397+
fallback_type: "issue",
398+
// pull_request_url present by shape, but explicit fallback_type should win
399+
pull_request_url: "https://github.com/owner/repo/pull/72",
400+
issue_number: 123,
401+
issue_url: "https://github.com/owner/repo/issues/123",
402+
repo: "owner/repo",
403+
},
404+
message: {
405+
body: "Pushing to PR branch.",
406+
},
407+
};
408+
409+
const summary = generateSafeOutputSummary(options);
410+
411+
expect(summary).toContain("Fallback Issue Created");
412+
expect(summary).toContain("Fallback Issue:");
413+
expect(summary).toContain("https://github.com/owner/repo/issues/123");
414+
expect(summary).not.toContain("Fallback Pull Request Created");
415+
});
363416
});
364417

365418
describe("writeSafeOutputSummaries", () => {

actions/setup/js/types/handler-factory.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface HandlerConfig {
1818
protected_path_prefixes?: string[];
1919
/** Policy for how protected file matches are handled: "blocked" (default), "fallback-to-issue", or "allowed" */
2020
protected_files_policy?: string;
21+
/** When true (default), create a fallback pull request if direct push to PR branch fails with non-fast-forward/diverged branch. */
22+
fallback_as_pull_request?: boolean;
2123
/** Additional handler-specific configuration properties */
2224
[key: string]: any;
2325
}

docs/src/content/docs/reference/frontmatter-full.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4599,6 +4599,13 @@ safe-outputs:
45994599
# (optional)
46004600
github-token-for-extra-empty-commit: "example-value"
46014601

4602+
# When true (default), if pushing to the PR branch fails due to a
4603+
# non-fast-forward/diverged branch, create a fallback pull request that targets
4604+
# the original PR branch. Set to false to disable this behavior and avoid
4605+
# requiring pull-requests: write permission.
4606+
# (optional)
4607+
fallback-as-pull-request: true
4608+
46024609
# Target repository in format 'owner/repo' for cross-repository push to pull
46034610
# request branch. Takes precedence over trial target repo settings.
46044611
# (optional)

0 commit comments

Comments
 (0)