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
7 changes: 5 additions & 2 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const {
generatePatchPreview,
buildManifestProtectionCreatePrUrl,
renderManifestProtectionFallbackBody,
buildPushErrorSection,
} = require("./create_pull_request_helpers.cjs");

/**
Expand Down Expand Up @@ -1736,14 +1737,15 @@ async function main(config = {}) {
const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(getErrorMessage(pushError)), { allowedAliases: allowedMentionAliases })
.replace(/\s+/g, " ")
.trim();
const pushErrorSection = buildPushErrorSection(getErrorMessage(pushError), pushFailureMessage);
const fallbackBody = `${issueSafeBody}

---

> [!NOTE]
> This was originally intended as a pull request, but the git push operation failed.
>
> **Original error:** ${pushFailureMessage}
${pushErrorSection}
>
> **Workflow Run:** [View run details and download bundle artifact](${runUrl})
>
Expand Down Expand Up @@ -2101,14 +2103,15 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead
const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(getErrorMessage(pushError)), { allowedAliases: allowedMentionAliases })
.replace(/\s+/g, " ")
.trim();
const pushErrorSection = buildPushErrorSection(getErrorMessage(pushError), pushFailureMessage);
const fallbackBody = `${issueSafeBody}

---

> [!NOTE]
> This was originally intended as a pull request, but the git push operation failed.
>
> **Original error:** ${pushFailureMessage}
${pushErrorSection}
>
> **Workflow Run:** [View run details and download patch artifact](${runUrl})
>
Expand Down
75 changes: 75 additions & 0 deletions actions/setup/js/create_pull_request_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,80 @@ function buildManifestProtectionCreatePrUrl(githubServer, repoParts, baseBranch,
return createPrUrl;
}

/**
* Build a formatted markdown error section for a push-failure note block.
*
* When the raw error message matches the `pushSignedCommits: refusing unsigned push`
* pattern the section is expanded into a structured block that names the cause and
* lists remediation steps. For all other errors it degrades gracefully to a single
* `**Original error:**` line using the sanitised, whitespace-collapsed form.
*
* The returned string contains one or more blockquote lines (lines starting with `>`)
* with no leading or trailing blank lines. It is designed to be embedded directly
* inside a `> [!NOTE]` block in a GitHub issue body, for example:
*
* ```
* > [!NOTE]
* > Intro sentence.
* >
* ${buildPushErrorSection(rawMsg, sanitizedMsg)}
* >
* > **Workflow Run:** [details](url)
* ```
*
* @param {string} rawErrorMessage - Unprocessed error message; used for pattern detection and cause extraction.
* @param {string} sanitizedErrorMessage - Sanitised, whitespace-collapsed message; used as the fallback line.
* @returns {string} One or more blockquote lines for the error section (no leading/trailing blank lines).
*/
function buildPushErrorSection(rawErrorMessage, sanitizedErrorMessage) {
// Only render the structured block for PushSignedCommitsUnsupportedShape errors,
// identified by the unique "cannot represent" boilerplate text. PushSignedCommitsPolicyViolation
// errors also start with "refusing unsigned push" but lack this boilerplate, so they fall
// through to the sanitized original-error fallback.
if (!/pushSignedCommits: refusing unsigned push/.test(rawErrorMessage) || !/createCommitOnBranch GraphQL mutation cannot represent/.test(rawErrorMessage)) {
return `> **Original error:** ${sanitizedErrorMessage}`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[^.]+ cause regex truncates at the first period: any cause text containing a . (e.g. executable file at path ./scripts/run.sh detected) is silently truncated, leaving a misleading partial message in the issue body.

💡 Suggested fix

Capture up to the sentence-ending period more carefully, or use a greedier stop before the next \n or end-of-line:

// Stop at '. ' (period+space) or end-of-line instead of any period
const causeMatch = rawErrorMessage.match(/refusing unsigned push for branch '[^']*': ([^\n]+?)(?:\. |$)/);

Alternatively, if the upstream error format always puts the cause between the colon and the first . , use a non-greedy match against . specifically:

/refusing unsigned push for branch '[^']*': (.+?)(?=\. )/

Either way, add a test case with a cause that contains a period character.

// Extract the specific cause (e.g. "merge commit detected") anchored to the boilerplate.
// Using '.*?' (lazy) instead of '[^']*' (restricted) handles apostrophes in branch names.
const causeMatch = rawErrorMessage.match(/refusing unsigned push for branch '.*?': ([^.]+?)(?=\. GitHub's createCommitOnBranch)/);
const cause = causeMatch ? causeMatch[1].trim() : "unsupported commit shape";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsanitized cause injected into markdown: cause is captured from rawErrorMessage — the unprocessed git error — and interpolated directly into the output string without any escaping. A crafted remote branch name or git error text could embed arbitrary markdown (extra blockquote lines, @mentions, closing keywords, links) into the fallback issue body.

💡 Suggested fix

Extract the cause from sanitizedErrorMessage instead, or sanitize the captured group before embedding it:

const causeMatch = sanitizedErrorMessage.match(/refusing unsigned push for branch [^:]*: ([^.]+)/);
const cause = causeMatch ? causeMatch[1].trim() : "unsupported commit shape";

sanitizedErrorMessage is already processed through sanitizeContent + neutralizeClosingKeywordsForIssueBody at the call site. The raw message is fine for the guard regex, but any captured text that lands in the output must come from the sanitized path.

const remediationLines = _remediationForCause(cause);

return [
`> **Error:** Signed commit push refused — ${cause}`,
`>`,
`> GitHub's \`createCommitOnBranch\` GraphQL API cannot represent:`,
`> - Merge commits`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The bullet list always enumerates all four unsupported shapes regardless of which cause was detected, which can mislead users.

If the cause is submodule change detected, listing Merge commits as equally applicable implies the commit has both problems. The test suite does not verify this — a test that checks only relevant shapes appear for each cause would catch this.

💡 Suggested improvement

Either scope the bullet list to shapes relevant to the detected cause, or make the intro more neutral:

`> GitHub's \`createCommitOnBranch\` GraphQL API does not support any of the following commit shapes:`

A coverage-based test would look like:

it('does not list merge commits for submodule cause', () => {
  const raw = "pushSignedCommits: refusing unsigned push for branch 'b': submodule change detected.";
  expect(buildPushErrorSection(raw, raw)).not.toContain('Merge commits');
});

@copilot please address this.

`> - Symlinks (mode \`120000\`)`,
`> - Submodule entries (mode \`160000\`)`,
`> - Executable files (mode \`100755\`)`,
`>`,
...remediationLines,
].join("\n");
}

/**
* Returns cause-specific remediation lines for a signed-commits push refusal.
* @param {string} cause - The extracted cause string from the error message.
* @returns {string[]} Two blockquote lines: the fix instruction and the unsigned-push alternative.
*/
function _remediationForCause(cause) {
const c = cause.toLowerCase();
let rewriteInstruction;
if (c.includes("merge commit")) {
rewriteInstruction = "Use `git rebase` instead of `git merge` to rewrite the commit history without merge commits";
} else if (c.includes("submodule")) {
rewriteInstruction = "Remove the submodule entry from the commit history";
} else if (c.includes("symlink")) {
rewriteInstruction = "Remove the symlink from the commit history";
} else {
rewriteInstruction = "Rewrite the commits to use only regular files (mode `100644`) with no merge commits or special entries";
}
return [`> **To fix:** ${rewriteInstruction},`, `> or set \`signed-commits: false\` in your workflow step if signed commits are not required.`];
}

/**
* Renders protected-files fallback issue body with a prefilled compare URL.
* @param {string} mainBodyContent
Expand Down Expand Up @@ -271,4 +345,5 @@ module.exports = {
generatePatchPreview,
buildManifestProtectionCreatePrUrl,
renderManifestProtectionFallbackBody,
buildPushErrorSection,
};
106 changes: 106 additions & 0 deletions actions/setup/js/create_pull_request_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const {
neutralizeClosingKeywordsForIssueBody,
generatePatchPreview,
buildManifestProtectionCreatePrUrl,
buildPushErrorSection,
} = require("./create_pull_request_helpers.cjs");

describe("create_pull_request_helpers - constants", () => {
Expand Down Expand Up @@ -480,3 +481,108 @@ describe("buildManifestProtectionCreatePrUrl", () => {
expect(url.startsWith("https://github.example.com/")).toBe(true);
});
});

// ---------------------------------------------------------------------------
// buildPushErrorSection
// ---------------------------------------------------------------------------
describe("buildPushErrorSection", () => {
const SIGNED_COMMITS_RAW =
"pushSignedCommits: refusing unsigned push for branch 'my-branch': merge commit detected. " +
"GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " +
"submodule entries (mode 160000), or executable bits (mode 100755). " +
"Rewrite the commits to use only regular files (mode 100644) with no merge commits, " +
"or set signed-commits: false if the repository does not require signed commits.";
const SIGNED_COMMITS_SANITIZED = SIGNED_COMMITS_RAW.replace(/\s+/g, " ").trim();

it("returns a generic fallback line for non-signed-commits errors", () => {
const raw = "Some other push error occurred";
const sanitized = "Some other push error occurred";
const result = buildPushErrorSection(raw, sanitized);
expect(result).toBe(`> **Original error:** ${sanitized}`);
});

it("returns structured markdown for a signed-commits refusal", () => {
const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED);
expect(result).toContain("> **Error:** Signed commit push refused");
expect(result).not.toContain("Original error:");
});

it("extracts the cause from the error message", () => {
const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED);
expect(result).toContain("merge commit detected");
});

it("lists all unsupported commit shapes", () => {
const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED);
expect(result).toContain("Merge commits");
expect(result).toContain("Symlinks");
expect(result).toContain("Submodule entries");
expect(result).toContain("Executable files");
});

it("includes merge-commit-specific remediation (git rebase) and signed-commits: false", () => {
const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED);
expect(result).toContain("git rebase");
expect(result).toContain("signed-commits: false");
});

it("produces only blockquote lines (each line starts with '>')", () => {
const result = buildPushErrorSection(SIGNED_COMMITS_RAW, SIGNED_COMMITS_SANITIZED);
const lines = result.split("\n");
for (const line of lines) {
expect(line.startsWith(">")).toBe(true);
}
});

it("falls back to generic error line for PushSignedCommitsPolicyViolation (no cannot-represent boilerplate)", () => {
const raw = "pushSignedCommits: refusing unsigned push for branch 'my-branch': " + "E003: Signed-commit payload exceeds max-patch-files (10). Synthesized payload touches 15 file(s): a.txt, b.txt.";
const sanitized = raw.replace(/\s+/g, " ").trim();
const result = buildPushErrorSection(raw, sanitized);
expect(result).toBe(`> **Original error:** ${sanitized}`);
});

it("uses a generic cause when the cause pattern does not match", () => {
// Has the boilerplate but lacks the standard 'for branch ...' extraction pattern.
const malformed = "pushSignedCommits: refusing unsigned push without branch info. " + "GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits.";
const result = buildPushErrorSection(malformed, malformed);
expect(result).toContain("unsupported commit shape");
});

it("extracts cause correctly from branch name containing an apostrophe", () => {
const raw =
"pushSignedCommits: refusing unsigned push for branch 'it's-great': merge commit detected. " +
"GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " +
"submodule entries (mode 160000), or executable bits (mode 100755). " +
"Rewrite the commits to use only regular files (mode 100644) with no merge commits, " +
"or set signed-commits: false if the repository does not require signed commits.";
const result = buildPushErrorSection(raw, raw);
expect(result).toContain("merge commit detected");
expect(result).not.toContain("unsupported commit shape");
});

it("handles submodule change detected cause with submodule-specific remediation", () => {
const raw =
"pushSignedCommits: refusing unsigned push for branch 'feat': submodule change detected. " +
"GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " +
"submodule entries (mode 160000), or executable bits (mode 100755). " +
"Rewrite the commits to use only regular files (mode 100644) with no merge commits, " +
"or set signed-commits: false if the repository does not require signed commits.";
const result = buildPushErrorSection(raw, raw);
expect(result).toContain("submodule change detected");
expect(result).toContain("Remove the submodule entry");
expect(result).not.toContain("git rebase");
});

it("handles symlink cause with symlink-specific remediation", () => {
const raw =
"pushSignedCommits: refusing unsigned push for branch 'feat': symlink file mode requires git push fallback. " +
"GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), " +
"submodule entries (mode 160000), or executable bits (mode 100755). " +
"Rewrite the commits to use only regular files (mode 100644) with no merge commits, " +
"or set signed-commits: false if the repository does not require signed commits.";
const result = buildPushErrorSection(raw, raw);
expect(result).toContain("symlink file mode requires git push fallback");
expect(result).toContain("Remove the symlink");
expect(result).not.toContain("git rebase");
});
});
Loading