Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ async function main(config = {}) {
const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
const ifNoChanges = config.if_no_changes || "warn";
const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true;
const fallbackAsPullRequest = config.fallback_as_pull_request !== false;
const commitTitleSuffix = config.commit_title_suffix || "";
const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024;
const maxCount = config.max || 0; // 0 means no limit
Expand Down Expand Up @@ -91,6 +92,7 @@ async function main(config = {}) {
}
core.info(`If no changes: ${ifNoChanges}`);
core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`);
core.info(`Fallback as pull request: ${fallbackAsPullRequest}`);
if (commitTitleSuffix) {
core.info(`Commit title suffix: ${commitTitleSuffix}`);
}
Expand Down Expand Up @@ -770,6 +772,58 @@ async function main(config = {}) {
core.warning(`Push failed and branch existence re-check errored for ${branchName}: ${getErrorMessage(diagnosisError)}`);
}

// Fallback path for diverged branches: create a new pull request so changes
// can still be reviewed and merged into the original PR branch.
if (isNonFastForward && fallbackAsPullRequest) {
const fallbackBranchName = normalizeBranchName(`${branchName}-fallback`, String(Date.now()));
core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`);
try {
await exec.exec("git", ["checkout", "-b", fallbackBranchName]);
await exec.exec("git", ["push", "origin", fallbackBranchName], {
env: { ...process.env, ...gitAuthEnv },
});

const fallbackBody = [
"> [!NOTE]",
"> Direct push to the original pull request branch failed because the branch diverged (non-fast-forward).",
`> Original PR branch: \`${branchName}\``,
"",
`This fallback PR contains the prepared changes for PR #${pullNumber}.`,
"Merge this fallback PR into the original PR branch to apply them.",
"",
`Workflow run: ${buildWorkflowRunUrl(context, context.repo)}`,
].join("\n");

const { data: fallbackPR } = await githubClient.rest.pulls.create({
owner: repoParts.owner,
repo: repoParts.repo,
title: `[fallback] ${prTitle || `Changes for #${pullNumber}`}`,
body: fallbackBody,
head: fallbackBranchName,
base: branchName,
});

core.info(`Created fallback pull request #${fallbackPR.number}: ${fallbackPR.html_url}`);
await updateActivationComment(github, context, core, fallbackPR.html_url, fallbackPR.number, "pull_request");

return {
success: true,
fallback_used: true,
fallback_type: "pull_request",
pull_request_number: fallbackPR.number,
pull_request_url: fallbackPR.html_url,
branch_name: fallbackBranchName,
repo: itemRepo,
number: fallbackPR.number,
url: fallbackPR.html_url,
};
} catch (fallbackError) {
const fallbackErrorMessage = getErrorMessage(fallbackError);
core.error(`Failed to create fallback pull request: ${fallbackErrorMessage}`);
userMessage = `${userMessage} Fallback pull request creation also failed: ${fallbackErrorMessage}`;
}
}

return { success: false, error_type: "push_failed", error: userMessage };
}

Expand Down
42 changes: 40 additions & 2 deletions actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ describe("push_to_pull_request_branch.cjs", () => {
labels: [],
},
}),
create: vi.fn().mockResolvedValue({
data: {
number: 999,
html_url: "https://github.com/test-owner/test-repo/pull/999",
},
}),
},
repos: {
get: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -767,7 +773,7 @@ index 0000000..abc1234
expect(mockCore.info).toHaveBeenCalledWith("Investigating patch failure...");
});

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

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

// The error happens during push
expect(result.success).toBe(true);
expect(result.fallback_used).toBe(true);
expect(result.fallback_type).toBe("pull_request");
expect(result.pull_request_number).toBe(999);
expect(mockGithub.rest.pulls.create).toHaveBeenCalled();
});

it("should not create fallback pull request when fallback-as-pull-request is disabled", async () => {
const patchPath = createPatchFile();

mockExec.exec.mockResolvedValueOnce(0); // fetch
mockExec.exec.mockResolvedValueOnce(0); // rev-parse
mockExec.exec.mockResolvedValueOnce(0); // checkout

mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)

mockExec.exec.mockResolvedValueOnce(0); // git am

mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" }); // git rev-list
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "remote-oid\trefs/heads/feature-branch\n", stderr: "" }); // git ls-remote
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "Test commit\n", stderr: "" }); // git log -1
mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // git diff --name-status

mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)"));

const module = await loadModule();
const handler = await module.main({ fallback_as_pull_request: false });
const result = await handler({ patch_path: patchPath }, {});

expect(result.success).toBe(false);
expect(result.error_type).toBe("push_failed");
expect(result.error).toContain("non-fast-forward");
expect(mockGithub.rest.pulls.create).not.toHaveBeenCalled();
});

it("should diagnose deleted branch when push fails", async () => {
Expand Down
36 changes: 27 additions & 9 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,10 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
/** @type {Array<{type: string, error: string}>} */
const codePushFailures = [];

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

// Load custom safe output job types (from GH_AW_SAFE_OUTPUT_JOBS env var)
Expand Down Expand Up @@ -481,9 +481,12 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
// If a previous code-push operation fell back to a review issue, prepend a correction note
// so the posted comment accurately reflects the outcome.
if (codePushFallbackInfo) {
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`;
const fallbackNote =
codePushFallbackInfo.fallbackTargetType === "pull_request"
? `\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`
: `\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`;
effectiveMessage = { ...effectiveMessage, body: fallbackNote + (effectiveMessage.body || "") };
core.info(`Prepending fallback correction note to add_comment body (fallback issue: #${codePushFallbackInfo.issueNumber})`);
core.info(`Prepending fallback correction note to add_comment body (fallback ${codePushFallbackInfo.fallbackTargetType}: #${codePushFallbackInfo.number})`);
}
// If a previous code-push operation failed outright (e.g. patch application error),
// prepend a failure warning so the status comment accurately reflects that the
Expand Down Expand Up @@ -585,11 +588,26 @@ async function processMessages(messageHandlers, messages, onItemCreated = null)
}
}

// Track when a code-push operation falls back to a review issue so subsequent
// Track when a code-push operation falls back to an issue or pull request so subsequent
// add_comment messages can include a correction note.
if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true && result.issue_number != null && result.issue_url) {
codePushFallbackInfo = { type: messageType, issueNumber: result.issue_number, issueUrl: result.issue_url };
core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`);
if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true) {
if (result.issue_number != null && result.issue_url) {
codePushFallbackInfo = {
type: messageType,
fallbackTargetType: "issue",
number: result.issue_number,
url: result.issue_url,
};
core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`);
} else if (result.pull_request_number != null && result.pull_request_url) {
codePushFallbackInfo = {
type: messageType,
fallbackTargetType: "pull_request",
number: result.pull_request_number,
url: result.pull_request_url,
};
core.info(`Code push '${messageType}' fell back to pull request #${result.pull_request_number} — add_comment messages will be annotated`);
}
}

// Check if this output was created with unresolved temporary IDs
Expand Down
28 changes: 28 additions & 0 deletions actions/setup/js/safe_output_handler_manager.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,34 @@ describe("Safe Output Handler Manager", () => {
expect(calledMessage.body).toContain("#7");
});

it("should prepend fallback note to add_comment body when push_to_pull_request_branch falls back to pull request", async () => {
const messages = [
{ type: "push_to_pull_request_branch", branch: "fix-branch" },
{ type: "add_comment", body: "Changes pushed." },
];

const pushHandler = vi.fn().mockResolvedValue({
success: true,
fallback_used: true,
fallback_type: "pull_request",
pull_request_number: 71,
pull_request_url: "https://github.com/owner/repo/pull/71",
});
const commentHandler = vi.fn().mockResolvedValue([{ _tracking: null }]);

const handlers = new Map([
["push_to_pull_request_branch", pushHandler],
["add_comment", commentHandler],
]);

await processMessages(handlers, messages);

const calledMessage = commentHandler.mock.calls[0][0];
expect(calledMessage.body).toContain("Direct push to the original pull request branch was not possible");
expect(calledMessage.body).toContain("#71");
expect(calledMessage.body).toContain("https://github.com/owner/repo/pull/71");
});

it("should NOT prepend fallback note when create_pull_request succeeds normally", async () => {
const messages = [
{ type: "create_pull_request", title: "My Fix PR" },
Expand Down
33 changes: 23 additions & 10 deletions actions/setup/js/safe_output_summary.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,41 @@ function generateSafeOutputSummary(options) {
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");

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

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

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

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

if (isFallback) {
// Explain why the fallback occurred and show the created issue
summary += `> ℹ️ Pull request creation was blocked due to protected file changes. A review issue was created instead.\n\n`;
if (result.issue_url) {
summary += `**Fallback Issue:** ${result.issue_url}\n\n`;
}
if (result.issue_number != null && result.repo) {
summary += `**Location:** ${result.repo}#${result.issue_number}\n\n`;
// Explain why the fallback occurred and show the created fallback target
if (fallbackType === "pull_request") {
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`;
if (result.pull_request_url) {
summary += `**Fallback Pull Request:** ${result.pull_request_url}\n\n`;
}
if (result.pull_request_number != null && result.repo) {
summary += `**Location:** ${result.repo}#${result.pull_request_number}\n\n`;
}
} else {
summary += `> ℹ️ Pull request creation was blocked due to protected file changes. A review issue was created instead.\n\n`;
if (result.issue_url) {
summary += `**Fallback Issue:** ${result.issue_url}\n\n`;
}
if (result.issue_number != null && result.repo) {
summary += `**Location:** ${result.repo}#${result.issue_number}\n\n`;
}
}
if (result.branch_name) {
summary += `**Branch:** \`${result.branch_name}\`\n\n`;
Expand Down
53 changes: 53 additions & 0 deletions actions/setup/js/safe_output_summary.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,59 @@ describe("safe_output_summary", () => {
expect(summary).not.toContain("⚠️");
expect(summary).not.toContain("Fallback");
});

it("should show fallback pull request status when push_to_pull_request_branch falls back to pull request", () => {
const options = {
type: "push_to_pull_request_branch",
messageIndex: 3,
success: true,
result: {
fallback_used: true,
fallback_type: "pull_request",
pull_request_number: 71,
pull_request_url: "https://github.com/owner/repo/pull/71",
repo: "owner/repo",
},
message: {
body: "Pushing to PR branch.",
},
};

const summary = generateSafeOutputSummary(options);

expect(summary).toContain("⚠️");
expect(summary).toContain("Fallback Pull Request Created");
expect(summary).toContain("https://github.com/owner/repo/pull/71");
expect(summary).toContain("owner/repo#71");
expect(summary).toContain("non-fast-forward");
});

it("should prefer explicit fallback_type over inferred shape for backward compatibility", () => {
const options = {
type: "push_to_pull_request_branch",
messageIndex: 4,
success: true,
result: {
fallback_used: true,
fallback_type: "issue",
// pull_request_url present by shape, but explicit fallback_type should win
pull_request_url: "https://github.com/owner/repo/pull/72",
issue_number: 123,
issue_url: "https://github.com/owner/repo/issues/123",
repo: "owner/repo",
},
message: {
body: "Pushing to PR branch.",
},
};

const summary = generateSafeOutputSummary(options);

expect(summary).toContain("Fallback Issue Created");
expect(summary).toContain("Fallback Issue:");
expect(summary).toContain("https://github.com/owner/repo/issues/123");
expect(summary).not.toContain("Fallback Pull Request Created");
});
});

describe("writeSafeOutputSummaries", () => {
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/types/handler-factory.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ interface HandlerConfig {
protected_path_prefixes?: string[];
/** Policy for how protected file matches are handled: "blocked" (default), "fallback-to-issue", or "allowed" */
protected_files_policy?: string;
/** When true (default), create a fallback pull request if direct push to PR branch fails with non-fast-forward/diverged branch. */
fallback_as_pull_request?: boolean;
/** Additional handler-specific configuration properties */
[key: string]: any;
}
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -4599,6 +4599,13 @@ safe-outputs:
# (optional)
github-token-for-extra-empty-commit: "example-value"

# When true (default), if pushing to the PR branch fails due to a
# non-fast-forward/diverged branch, create a fallback pull request that targets
# the original PR branch. Set to false to disable this behavior and avoid
# requiring pull-requests: write permission.
# (optional)
fallback-as-pull-request: true

# Target repository in format 'owner/repo' for cross-repository push to pull
# request branch. Takes precedence over trial target repo settings.
# (optional)
Expand Down
Loading