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
68 changes: 37 additions & 31 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const {
buildManifestProtectionCreatePrUrl,
renderManifestProtectionFallbackBody,
buildPushErrorSection,
buildManualBranchRecoveryCommands,
shellQuote,
} = require("./create_pull_request_helpers.cjs");
const { isStackedEnabled, parseStackMetadata, hasCircularStackDependency, buildStackMetadataLines, stackedDisabledError, circularStackError, verifyStackBaseBranchExists, createStackTracker } = require("./stacked_pull_requests.cjs");

Expand Down Expand Up @@ -1905,8 +1907,14 @@ async function main(config = {}) {
const runId = context.runId;

const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle";
const fallbackBundleSourceRef = `refs/heads/${originalAgentBranch || branchName}`;
const fallbackBundleTempRef = createBundleTempRef(branchName);
const recoveryInstructions = buildManualBranchRecoveryCommands({
hasBundleFile: true,
runId,
artifactFileName,
branchName,
baseBranch,
tempRef: createBundleTempRef(branchName),
});
const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(getErrorMessage(pushError)), { allowedAliases: allowedMentionAliases })
.replace(/\s+/g, " ")
.trim();
Expand All @@ -1927,23 +1935,13 @@ ${pushErrorSection}
To create a pull request with the changes:

\`\`\`sh
# Download the artifact from the workflow run
gh run download ${runId} -n agent -D /tmp/agent-${runId}

# Fetch the bundle into a temporary ref, then update the local branch
git fetch /tmp/agent-${runId}/${artifactFileName} ${fallbackBundleSourceRef}:${fallbackBundleTempRef}
git update-ref refs/heads/${branchName} ${fallbackBundleTempRef}
git checkout ${branchName}
# Ensure the working tree matches the updated branch
git reset --hard
# Remove the temporary bundle ref
git update-ref -d ${fallbackBundleTempRef}
${recoveryInstructions}

# Push the branch to origin
git push ${pushRemoteUrl || "origin"} ${branchName}
# Push the branch to the target remote
git push ${shellQuote(pushRemoteUrl || "origin")} ${shellQuote(branchName)}

# Create the pull request
gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHeadRef(branchName)} --repo ${repoParts.owner}/${repoParts.repo}
gh pr create --title ${shellQuote(title)} --base ${shellQuote(baseBranch)} --head ${shellQuote(getPullRequestHeadRef(branchName))} --repo ${shellQuote(`${repoParts.owner}/${repoParts.repo}`)}
\`\`\``;

try {
Expand Down Expand Up @@ -2277,6 +2275,13 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHead
}

const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
const recoveryInstructions = buildManualBranchRecoveryCommands({
hasBundleFile: false,
runId,
artifactFileName: patchFileName,
branchName,
baseBranch,
});
const pushFailureMessage = sanitizeContent(neutralizeClosingKeywordsForIssueBody(getErrorMessage(pushError)), { allowedAliases: allowedMentionAliases })
.replace(/\s+/g, " ")
.trim();
Expand All @@ -2297,20 +2302,13 @@ ${pushErrorSection}
To create a pull request with the changes:

\`\`\`sh
# Download the artifact from the workflow run
gh run download ${runId} -n agent -D /tmp/agent-${runId}
${recoveryInstructions}

# Create a new branch
git checkout -b ${branchName}

# Apply the patch (--3way handles cross-repo patches where files may already exist)
git am --3way /tmp/agent-${runId}/${patchFileName}

# Push the branch to origin
git push ${pushRemoteUrl || "origin"} ${branchName}
# Push the branch to the target remote
git push ${shellQuote(pushRemoteUrl || "origin")} ${shellQuote(branchName)}

# Create the pull request
gh pr create --title '${title}' --base ${baseBranch} --head ${getPullRequestHeadRef(branchName)} --repo ${repoParts.owner}/${repoParts.repo}
gh pr create --title ${shellQuote(title)} --base ${shellQuote(baseBranch)} --head ${shellQuote(getPullRequestHeadRef(branchName))} --repo ${shellQuote(`${repoParts.owner}/${repoParts.repo}`)}
\`\`\`
${patchPreview}`;

Expand Down Expand Up @@ -2461,18 +2459,26 @@ ${patchPreview}`;
let fallbackBody;
if (manifestProtectionPushFailedError) {
// Push failed — branch not on remote, so compare URL is unavailable.
// Use the push-failed template with artifact download instructions.
// Use the push-failed template with artifact download instructions, matching
// whichever transport (bundle or format-patch) was actually used to encode the changes.
const runId = context.runId;
const patchFileName = patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
const artifactFileName = hasBundleFile ? bundleFilePath.replace("/tmp/gh-aw/", "") : patchFilePath ? patchFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.patch";
const applyInstructions = buildManualBranchRecoveryCommands({
hasBundleFile,
runId,
artifactFileName,
branchName,
baseBranch,
tempRef: createBundleTempRef(branchName),
});
const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md");
fallbackBody = renderTemplateFromFile(pushFailedTemplatePath, {
main_body: issueSafeMainBodyContent,
footer: footerContent,
files: fileList,
run_id: String(runId),
apply_instructions: applyInstructions,
branch_name: branchName,
base_branch: baseBranch,
patch_file: patchFileName,
title,
repo: `${repoParts.owner}/${repoParts.repo}`,
});
Expand Down
85 changes: 82 additions & 3 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1211,14 +1211,16 @@ index 0000000..abc1234
expect(result.fallback_used).toBe(true);

const fallbackIssueBody = global.github.rest.issues.create.mock.calls[0][0].body;
const tempRefMatch = fallbackIssueBody.match(/refs\/heads\/autoloop\/perf-comparison:(refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8})/);
const tempRefMatch = fallbackIssueBody.match(/temp_ref='(refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8})'/);
if (!tempRefMatch?.[1]) {
throw new Error("expected fallback bundle temp ref");
}
const fallbackBundleTempRef = tempRefMatch[1];
expect(fallbackIssueBody).toContain(`git update-ref refs/heads/autoloop/perf-comparison ${fallbackBundleTempRef}`);
expect(fallbackIssueBody).toContain("target_ref='refs/heads/autoloop/perf-comparison'");
expect(fallbackIssueBody).toContain('git update-ref "$target_ref" "$temp_ref"');
expect(fallbackIssueBody).toContain("git reset --hard");
expect(fallbackIssueBody).toContain(`git update-ref -d ${fallbackBundleTempRef}`);
expect(fallbackIssueBody).toContain('git update-ref -d "$temp_ref"');
expect(fallbackIssueBody).toContain(fallbackBundleTempRef);
expect(fallbackIssueBody).not.toContain("refs/heads/autoloop/perf-comparison:refs/heads/autoloop/perf-comparison");
expect(fallbackIssueBody).toContain("**Original error:** push rejected");
expect(fallbackIssueBody).toContain("Test body");
Expand Down Expand Up @@ -2151,6 +2153,83 @@ ${diffs}
expect(createCall.body).not.toContain("gh run download");
expect(createCall.body).not.toContain("git am --3way");
});

it("should give patch-based manual recovery instructions for protected-files push-failure fallback (patch transport)", async () => {
writePatch("feature/protected", createPatchWithFiles(".github/aw/instructions.md"));
const promptsDir = path.join(tempDir, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
copyPromptTemplate(promptsDir, "manifest_protection_create_pr_fallback.md");
copyPromptTemplate(promptsDir, "manifest_protection_push_failed_fallback.md");
copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md");
process.env.GH_AW_PROMPTS_DIR = promptsDir;

global.github.rest.issues = {
create: vi.fn().mockResolvedValue({ data: { number: 78, html_url: "https://github.com/test-owner/test-repo/issues/78" } }),
update: vi.fn().mockResolvedValue({ data: {} }),
};
pushSignedSpy.mockRejectedValueOnce(new Error("refusing to allow a GitHub App to create or update workflow"));

const { main } = require("./create_pull_request.cjs");
const handler = await main({
protected_path_prefixes: [".github/"],
protected_files_policy: "fallback-to-issue",
});
const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/protected" }, {});

expect(result.success).toBe(true);
expect(result.fallback_used).toBe(true);
expect(result.issue_number).toBe(78);

const createCall = global.github.rest.issues.create.mock.calls[0][0];
// No compare URL is possible since the branch was never pushed
expect(createCall.body).not.toContain("/compare/main...");
expect(createCall.body).toContain("gh run download");
// Patch transport: instructions should create a new branch off the base branch, then git am
expect(createCall.body).toMatch(/git checkout -b 'feature\/protected\S*' 'main'/);
expect(createCall.body).toContain("git am --3way");
expect(createCall.body).not.toContain("git update-ref");
});

it("should give bundle-based manual recovery instructions for protected-files push-failure fallback (bundle transport)", async () => {
writePatch("feature/protected", createPatchWithFiles(".github/aw/instructions.md"));
const bundlePath = canonicalBundlePath("feature/protected");
fs.writeFileSync(bundlePath, "bundle content");
const promptsDir = path.join(tempDir, "prompts");
fs.mkdirSync(promptsDir, { recursive: true });
copyPromptTemplate(promptsDir, "manifest_protection_create_pr_fallback.md");
copyPromptTemplate(promptsDir, "manifest_protection_push_failed_fallback.md");
copyPromptTemplate(promptsDir, "safe_outputs_disclosure_header.md");
process.env.GH_AW_PROMPTS_DIR = promptsDir;

global.github.rest.issues = {
create: vi.fn().mockResolvedValue({ data: { number: 79, html_url: "https://github.com/test-owner/test-repo/issues/79" } }),
update: vi.fn().mockResolvedValue({ data: {} }),
};
pushSignedSpy.mockRejectedValueOnce(new Error("refusing to allow a GitHub App to create or update workflow"));

const { main } = require("./create_pull_request.cjs");
const handler = await main({
protected_path_prefixes: [".github/"],
protected_files_policy: "fallback-to-issue",
});
const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/protected" }, {});

expect(result.success).toBe(true);
expect(result.fallback_used).toBe(true);
expect(result.issue_number).toBe(79);

const createCall = global.github.rest.issues.create.mock.calls[0][0];
// No compare URL is possible since the branch was never pushed
expect(createCall.body).not.toContain("/compare/main...");
expect(createCall.body).toContain("gh run download");
// Bundle transport: instructions should fetch the bundle into a temp ref and reset --hard,
// not use git am (which fails on bundle files - see issue #55509)
expect(createCall.body).toContain("git bundle list-heads");
expect(createCall.body).toContain('$2 == "HEAD"');
expect(createCall.body).toContain('git update-ref "$target_ref" "$temp_ref"');
expect(createCall.body).toContain("git reset --hard");
expect(createCall.body).not.toContain("git am --3way");
});
});

// excluded-files exclusion list
Expand Down
134 changes: 134 additions & 0 deletions actions/setup/js/create_pull_request_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ function createBundleTempRef(branchName) {
return `refs/bundles/create-pr-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}-${suffix}`;
}

/**
* Quote a value as a single POSIX shell argument.
* @param {string|number} value
* @returns {string}
*/
function shellQuote(value) {
const s = String(value);
if (s.length === 0) return "''";
return `'${s.replace(/'/g, `'\\''`)}'`;
}

/**
* Determines if a label API error is transient and worth retrying.
* Returns true for:
Expand Down Expand Up @@ -310,6 +321,126 @@ function _remediationForCause(cause) {
return [`> **To fix:** ${rewriteInstruction},`, `> or set \`signed-commits: false\` in your workflow step if signed commits are not required.`];
}

/**
* Build the shell instructions for manually recreating a branch (and later opening a PR)
* from a workflow-run artifact, matching whichever transport (bundle or format-patch)
* was actually used to encode the changes. Used when the automated push failed and a
* human needs to recreate the branch locally before pushing and opening the PR themselves.
*
* The returned block intentionally stops right before the final `git push` / `gh pr create`
* steps, which are appended separately by the caller since they are identical for both
* transports.
*
* @param {object} params
* @param {boolean} params.hasBundleFile - true when the artifact is a git bundle, false for a format-patch file
* @param {string|number} params.runId - workflow run id, used to build the `gh run download` command
* @param {string} params.artifactFileName - bundle or patch file name (relative to the artifact root)
* @param {string} params.branchName - branch to create/checkout locally
* @param {string} [params.baseBranch] - base branch to create the new branch from (patch transport only)
* @param {string} [params.tempRef] - temporary ref used while transplanting the bundle (bundle transport only)
* @returns {string} Shell instructions (no leading/trailing blank lines, no code fence)
*/
function buildManualBranchRecoveryCommands({ hasBundleFile, runId, artifactFileName, branchName, baseBranch, tempRef }) {
if (hasBundleFile) {
if (!tempRef) {
throw new Error("tempRef is required for bundle manual branch recovery commands");
}
const bundlePath = `/tmp/agent-${runId}/${artifactFileName}`;
const targetRef = `refs/heads/${branchName}`;
return [
`# Download the artifact from the workflow run`,
`gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`,
``,
`# Resolve the bundle source ref, fetch it into a temporary ref, then create the local branch`,
`bundle_path=${shellQuote(bundlePath)}`,
`temp_ref=${shellQuote(tempRef)}`,
`target_ref=${shellQuote(targetRef)}`,
`bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 ~ /^refs\\/heads\\// { print $2 }')`,
`if [ -z "$bundle_source_ref" ]; then`,
` bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 == "HEAD" { print $2 }')`,
`fi`,
`if [ "$(printf '%s\\n' "$bundle_source_ref" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then`,
` echo "Expected exactly one bundle source ref, found: $bundle_source_ref" >&2`,
` exit 1`,
`fi`,
`git fetch "$bundle_path" "\${bundle_source_ref}:\${temp_ref}"`,
`git update-ref "$target_ref" "$temp_ref"`,
`git checkout ${shellQuote(branchName)}`,
`# Ensure the working tree matches the updated branch`,
`git reset --hard`,
`# Remove the temporary bundle ref`,
`git update-ref -d "$temp_ref"`,
].join("\n");
}
if (!baseBranch) {
throw new Error("baseBranch is required for patch manual branch recovery commands");
}
return [
`# Download the artifact from the workflow run`,
`gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`,
``,
`# Create a new branch`,
`git checkout -b ${shellQuote(branchName)} ${shellQuote(baseBranch)}`,
``,
`# Apply the patch (--3way handles cross-repo patches where files may already exist)`,
`git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`,
].join("\n");
}

/**
* Build the shell instructions for manually applying a workflow-run artifact to an
* *existing* remote branch (e.g. an already-open pull request branch), matching whichever
* transport (bundle or format-patch) was actually used to encode the changes.
*
* Unlike {@link buildManualBranchRecoveryCommands}, the returned block is self-contained:
* it includes the final `git push`, since there is no separate PR-creation step for this flow.
*
* @param {object} params
* @param {boolean} params.hasBundleFile - true when the artifact is a git bundle, false for a format-patch file
* @param {string|number} params.runId - workflow run id, used to build the `gh run download` command
* @param {string} params.artifactFileName - bundle or patch file name (relative to the artifact root)
* @param {string} params.branchName - existing remote branch to update
* @param {string} [params.branchRemote] - remote name or URL containing the existing branch
* @returns {string} Shell instructions (no leading/trailing blank lines, no code fence)
*/
function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName, branchName, branchRemote = "origin" }) {
const bundlePath = `/tmp/agent-${runId}/${artifactFileName}`;
const pushRef = `HEAD:refs/heads/${branchName}`;
if (hasBundleFile) {
return [
`# Download the artifact from the workflow run`,
`gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`,
``,
`# Fetch the bundle into a temporary ref, then fast-forward the branch`,
`bundle_path=${shellQuote(bundlePath)}`,
`git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`,
`git checkout -B ${shellQuote(branchName)} FETCH_HEAD`,
`bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 ~ /^refs\\/heads\\// { print $2 }')`,
`if [ -z "$bundle_source_ref" ]; then`,
` bundle_source_ref=$(git bundle list-heads "$bundle_path" | awk '$2 == "HEAD" { print $2 }')`,
`fi`,
`if [ "$(printf '%s\\n' "$bundle_source_ref" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]; then`,
` echo "Expected exactly one bundle source ref, found: $bundle_source_ref" >&2`,
` exit 1`,
`fi`,
`git fetch "$bundle_path" "\${bundle_source_ref}:refs/bundles/manual-apply"`,
`git reset --hard refs/bundles/manual-apply`,
`git update-ref -d refs/bundles/manual-apply`,
`git push ${shellQuote(branchRemote)} ${shellQuote(pushRef)}`,
].join("\n");
}
return [
`# Download the artifact from the workflow run`,
`gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`,
``,
`# Apply the patch to the pull request branch`,
`git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`,
`git checkout -B ${shellQuote(branchName)} FETCH_HEAD`,
`git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`,
`git push ${shellQuote(branchRemote)} ${shellQuote(pushRef)}`,
].join("\n");
}

/**
* Renders protected-files fallback issue body with a prefilled compare URL.
* @param {string} mainBodyContent
Expand All @@ -335,6 +466,7 @@ module.exports = {
LABEL_MAX_DELAY_MS,
summarizeListForLog,
createBundleTempRef,
shellQuote,
isLabelTransientError,
parseAllowedBaseBranches,
isBaseBranchAllowed,
Expand All @@ -346,4 +478,6 @@ module.exports = {
buildManifestProtectionCreatePrUrl,
renderManifestProtectionFallbackBody,
buildPushErrorSection,
buildManualBranchRecoveryCommands,
buildManualBranchApplyCommands,
};
Loading
Loading