From 3807ce29fe5e11e0dac90ce608f81015df7ac79b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:47:31 +0000 Subject: [PATCH 1/4] Initial plan From 78fa55330e2ed27960b098c2eed6b9443b751a6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:08:07 +0000 Subject: [PATCH 2/4] Fix manual PR-recreation instructions to match actual patch/bundle transport Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 22 ++++- actions/setup/js/create_pull_request.test.cjs | 75 +++++++++++++++ .../setup/js/create_pull_request_helpers.cjs | 92 +++++++++++++++++++ .../setup/js/push_to_pull_request_branch.cjs | 15 ++- .../js/push_to_pull_request_branch.test.cjs | 43 +++++++++ ...anifest_protection_push_failed_fallback.md | 11 +-- ...manifest_protection_push_to_pr_fallback.md | 15 +-- 7 files changed, 247 insertions(+), 26 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 81e1488e188..c7e7e74dafe 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -59,6 +59,7 @@ const { buildManifestProtectionCreatePrUrl, renderManifestProtectionFallbackBody, buildPushErrorSection, + buildManualBranchRecoveryCommands, } = require("./create_pull_request_helpers.cjs"); const { isStackedEnabled, parseStackMetadata, hasCircularStackDependency, buildStackMetadataLines, stackedDisabledError, circularStackError, verifyStackBaseBranchExists, createStackTracker } = require("./stacked_pull_requests.cjs"); @@ -2461,18 +2462,31 @@ ${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, + sourceRef: `refs/heads/${originalAgentBranch || branchName}`, + 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}`, }); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 11935477d90..a07d5168535 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -2151,6 +2151,81 @@ ${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).toMatch(/git update-ref refs\/heads\/feature\/protected\S* refs\/bundles\//); + expect(createCall.body).toContain("git reset --hard"); + expect(createCall.body).not.toContain("git am --3way"); + }); }); // excluded-files exclusion list diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index 1257abcaa8c..67a16bb884e 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -310,6 +310,96 @@ 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.sourceRef] - source ref inside the bundle to fetch (bundle 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, sourceRef, tempRef }) { + if (hasBundleFile) { + return [ + `# 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 create the local branch`, + `git fetch /tmp/agent-${runId}/${artifactFileName} ${sourceRef}:${tempRef}`, + `git update-ref refs/heads/${branchName} ${tempRef}`, + `git checkout ${branchName}`, + `# Ensure the working tree matches the updated branch`, + `git reset --hard`, + `# Remove the temporary bundle ref`, + `git update-ref -d ${tempRef}`, + ].join("\n"); + } + return [ + `# Download the artifact from the workflow run`, + `gh run download ${runId} -n agent -D /tmp/agent-${runId}`, + ``, + `# Create a new branch`, + `git checkout -b ${branchName} ${baseBranch}`, + ``, + `# Apply the patch (--3way handles cross-repo patches where files may already exist)`, + `git am --3way /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 + * @returns {string} Shell instructions (no leading/trailing blank lines, no code fence) + */ +function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName, branchName }) { + if (hasBundleFile) { + return [ + `# 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 fast-forward the branch`, + `git fetch origin ${branchName}`, + `git checkout ${branchName}`, + `git fetch /tmp/agent-${runId}/${artifactFileName} refs/heads/${branchName}:refs/bundles/manual-apply`, + `git reset --hard refs/bundles/manual-apply`, + `git update-ref -d refs/bundles/manual-apply`, + `git push origin ${branchName}`, + ].join("\n"); + } + return [ + `# Download the artifact from the workflow run`, + `gh run download ${runId} -n agent -D /tmp/agent-${runId}`, + ``, + `# Apply the patch to the pull request branch`, + `git fetch origin ${branchName}`, + `git checkout ${branchName}`, + `git am --3way /tmp/agent-${runId}/${artifactFileName}`, + `git push origin ${branchName}`, + ].join("\n"); +} + /** * Renders protected-files fallback issue body with a prefilled compare URL. * @param {string} mainBodyContent @@ -346,4 +436,6 @@ module.exports = { buildManifestProtectionCreatePrUrl, renderManifestProtectionFallbackBody, buildPushErrorSection, + buildManualBranchRecoveryCommands, + buildManualBranchApplyCommands, }; diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 436cf5d19b8..7485b49ccba 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -25,6 +25,7 @@ const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const { getThreatWarningPresentation } = require("./threat_detection_warning.cjs"); const { attachExecutionState } = require("./safe_output_execution_metadata.cjs"); const { resolveTransportPaths } = require("./resolve_transport_paths.cjs"); +const { buildManualBranchApplyCommands } = require("./create_pull_request_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -815,7 +816,17 @@ async function main(config = {}) { const createProtectedFilesFallbackIssue = async files => { const runUrl = buildWorkflowRunUrl(context, context.repo); 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 = buildManualBranchApplyCommands({ + hasBundleFile, + runId, + artifactFileName, + branchName, + }); const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; const prUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/pull/${pullNumber}`; const issueTitle = `[gh-aw] Protected Files: ${prTitle || `PR #${pullNumber}`}`; @@ -828,7 +839,7 @@ async function main(config = {}) { run_url: runUrl, run_id: runId, branch_name: branchName, - patch_file_name: patchFileName, + apply_instructions: applyInstructions, }); try { diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index b2b81413740..d8ff42755a4 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -3256,6 +3256,49 @@ ${diffs} } }); + it("should give patch-based manual apply instructions for protected-files fallback (patch transport)", async () => { + createPatchFile("should-give-patch-based-manual-apply-instructions", createPatchWithFiles("package.json")); + + const module = await loadModule(); + const handler = await module.main({ + protected_files: ["package.json"], + protected_files_policy: "fallback-to-issue", + }); + const result = await handler({ branch: "should-give-patch-based-manual-apply-instructions" }, {}); + + expect(result.success).toBe(true); + expect(result.fallback_used).toBe(true); + + const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body; + expect(issueBody).toContain("gh run download"); + expect(issueBody).toContain("git am --3way"); + expect(issueBody).not.toContain("refs/bundles/"); + }); + + it("should give bundle-based manual apply instructions for protected-files fallback (bundle transport)", async () => { + createPatchFile("should-give-bundle-based-manual-apply-instructions", createPatchWithFiles("package.json")); + const bundlePath = canonicalBundlePath("should-give-bundle-based-manual-apply-instructions"); + fs.writeFileSync(bundlePath, "bundle content"); + + const module = await loadModule(); + const handler = await module.main({ + protected_files: ["package.json"], + protected_files_policy: "fallback-to-issue", + }); + const result = await handler({ branch: "should-give-bundle-based-manual-apply-instructions" }, {}); + + expect(result.success).toBe(true); + expect(result.fallback_used).toBe(true); + + const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body; + expect(issueBody).toContain("gh run download"); + // Bundle transport: instructions should fetch the bundle and reset --hard, + // not use git am (which fails on bundle files - see issue #55509) + expect(issueBody).toContain("refs/bundles/manual-apply"); + expect(issueBody).toContain("git reset --hard refs/bundles/manual-apply"); + expect(issueBody).not.toContain("git am --3way"); + }); + it("should block a protected file when no allowed-files list is configured", async () => { const patchPath = createPatchFile("should-block-a-protected-file-when-no-allowed-files-list-is-", createPatchWithFiles("package.json")); diff --git a/actions/setup/md/manifest_protection_push_failed_fallback.md b/actions/setup/md/manifest_protection_push_failed_fallback.md index 8520f17502a..6827e726f69 100644 --- a/actions/setup/md/manifest_protection_push_failed_fallback.md +++ b/actions/setup/md/manifest_protection_push_failed_fallback.md @@ -5,7 +5,7 @@ > [!WARNING] > **Protected Files — Push Permission Denied** > -> This was originally intended as a pull request, but the patch modifies protected files. A human must create the pull request manually. +> This was originally intended as a pull request, but the change modifies protected files. A human must create the pull request manually. > >
> Protected files @@ -20,14 +20,7 @@ Create the pull request manually ```sh -# Download the patch from the workflow run -gh run download {run_id} -n agent -D /tmp/agent-{run_id} - -# Create a new branch -git checkout -b {branch_name} {base_branch} - -# Apply the patch (--3way handles cross-repo patches) -git am --3way /tmp/agent-{run_id}/{patch_file} +{apply_instructions} # Push the branch and create the pull request git push origin {branch_name} diff --git a/actions/setup/md/manifest_protection_push_to_pr_fallback.md b/actions/setup/md/manifest_protection_push_to_pr_fallback.md index f664113f496..0120139ca90 100644 --- a/actions/setup/md/manifest_protection_push_to_pr_fallback.md +++ b/actions/setup/md/manifest_protection_push_to_pr_fallback.md @@ -1,7 +1,7 @@ > [!WARNING] > **Protected Files** > -> The push to pull request branch was blocked because the patch modifies protected files. +> The push to pull request branch was blocked because the change modifies protected files. > > **Target Pull Request:** [#{pull_number}]({pr_url}) > @@ -19,19 +19,12 @@
Apply the patch after review -The patch is available in the workflow run artifacts: +The changes are available in the workflow run artifacts: -**Workflow Run:** [View run details and download patch artifact]({run_url}) +**Workflow Run:** [View run details and download the artifact]({run_url}) ```sh -# Download the artifact from the workflow run -gh run download {run_id} -n agent -D /tmp/agent-{run_id} - -# Apply the patch to the pull request branch -git fetch origin {branch_name} -git checkout {branch_name} -git am --3way /tmp/agent-{run_id}/{patch_file_name} -git push origin {branch_name} +{apply_instructions} ```
From e1d40ac323e0a0f844b21acbc73bd848e60819ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:08:21 +0000 Subject: [PATCH 3/4] Fix manual bundle recovery instructions Co-authored-by: dsyme <7204669+dsyme@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 58 +++++------- actions/setup/js/create_pull_request.test.cjs | 14 ++- .../setup/js/create_pull_request_helpers.cjs | 85 ++++++++++++----- .../js/create_pull_request_helpers.test.cjs | 91 +++++++++++++++++++ .../setup/js/push_to_pull_request_branch.cjs | 7 +- .../js/push_to_pull_request_branch.test.cjs | 48 ++++++++++ 6 files changed, 238 insertions(+), 65 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index c7e7e74dafe..699089d7044 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -60,6 +60,7 @@ const { renderManifestProtectionFallbackBody, buildPushErrorSection, buildManualBranchRecoveryCommands, + shellQuote, } = require("./create_pull_request_helpers.cjs"); const { isStackedEnabled, parseStackMetadata, hasCircularStackDependency, buildStackMetadataLines, stackedDisabledError, circularStackError, verifyStackBaseBranchExists, createStackTracker } = require("./stacked_pull_requests.cjs"); @@ -1906,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(); @@ -1928,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 { @@ -2278,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(); @@ -2298,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} - -# 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} +${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}`)} \`\`\` ${patchPreview}`; @@ -2465,18 +2462,13 @@ ${patchPreview}`; // 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 artifactFileName = hasBundleFile - ? bundleFilePath.replace("/tmp/gh-aw/", "") - : 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, - sourceRef: `refs/heads/${originalAgentBranch || branchName}`, tempRef: createBundleTempRef(branchName), }); const pushFailedTemplatePath = getPromptPath("manifest_protection_push_failed_fallback.md"); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index a07d5168535..46a5959c0d5 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -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"); @@ -2183,7 +2185,7 @@ ${diffs} 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).toMatch(/git checkout -b 'feature\/protected\S*' 'main'/); expect(createCall.body).toContain("git am --3way"); expect(createCall.body).not.toContain("git update-ref"); }); @@ -2222,7 +2224,9 @@ ${diffs} 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).toMatch(/git update-ref refs\/heads\/feature\/protected\S* refs\/bundles\//); + 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"); }); diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index 67a16bb884e..68db2fd70c0 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -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: @@ -326,35 +337,53 @@ function _remediationForCause(cause) { * @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.sourceRef] - source ref inside the bundle to fetch (bundle 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, sourceRef, tempRef }) { +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 ${runId} -n agent -D /tmp/agent-${runId}`, + `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, - `# Fetch the bundle into a temporary ref, then create the local branch`, - `git fetch /tmp/agent-${runId}/${artifactFileName} ${sourceRef}:${tempRef}`, - `git update-ref refs/heads/${branchName} ${tempRef}`, - `git checkout ${branchName}`, + `# 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 ${tempRef}`, + `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 ${runId} -n agent -D /tmp/agent-${runId}`, + `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Create a new branch`, - `git checkout -b ${branchName} ${baseBranch}`, + `git checkout -b ${shellQuote(branchName)} ${shellQuote(baseBranch)}`, ``, `# Apply the patch (--3way handles cross-repo patches where files may already exist)`, - `git am --3way /tmp/agent-${runId}/${artifactFileName}`, + `git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`, ].join("\n"); } @@ -371,32 +400,43 @@ function buildManualBranchRecoveryCommands({ hasBundleFile, runId, artifactFileN * @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 }) { +function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName, branchName, branchRemote = "origin" }) { + const bundlePath = `/tmp/agent-${runId}/${artifactFileName}`; if (hasBundleFile) { return [ `# Download the artifact from the workflow run`, - `gh run download ${runId} -n agent -D /tmp/agent-${runId}`, + `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Fetch the bundle into a temporary ref, then fast-forward the branch`, - `git fetch origin ${branchName}`, - `git checkout ${branchName}`, - `git fetch /tmp/agent-${runId}/${artifactFileName} refs/heads/${branchName}:refs/bundles/manual-apply`, + `bundle_path=${shellQuote(bundlePath)}`, + `git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, + `git checkout ${shellQuote(branchName)}`, + `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 origin ${branchName}`, + `git push ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, ].join("\n"); } return [ `# Download the artifact from the workflow run`, - `gh run download ${runId} -n agent -D /tmp/agent-${runId}`, + `gh run download ${shellQuote(runId)} -n agent -D ${shellQuote(`/tmp/agent-${runId}`)}`, ``, `# Apply the patch to the pull request branch`, - `git fetch origin ${branchName}`, - `git checkout ${branchName}`, - `git am --3way /tmp/agent-${runId}/${artifactFileName}`, - `git push origin ${branchName}`, + `git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, + `git checkout ${shellQuote(branchName)}`, + `git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`, + `git push ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, ].join("\n"); } @@ -425,6 +465,7 @@ module.exports = { LABEL_MAX_DELAY_MS, summarizeListForLog, createBundleTempRef, + shellQuote, isLabelTransientError, parseAllowedBaseBranches, isBaseBranchAllowed, diff --git a/actions/setup/js/create_pull_request_helpers.test.cjs b/actions/setup/js/create_pull_request_helpers.test.cjs index 02ea0fd96ef..303a4f9f34b 100644 --- a/actions/setup/js/create_pull_request_helpers.test.cjs +++ b/actions/setup/js/create_pull_request_helpers.test.cjs @@ -2,6 +2,10 @@ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { createRequire } from "module"; import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; const require = createRequire(import.meta.url); @@ -32,8 +36,26 @@ const { generatePatchPreview, buildManifestProtectionCreatePrUrl, buildPushErrorSection, + buildManualBranchRecoveryCommands, + buildManualBranchApplyCommands, } = require("./create_pull_request_helpers.cjs"); +function runGit(args, cwd) { + const result = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + expect(result.status, `git ${args.join(" ")} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + return result.stdout.trim(); +} + +function runShell(script, cwd) { + const result = spawnSync("bash", ["-c", `set -euo pipefail\n${script}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + expect(result.status, `shell failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\nscript:\n${script}`).toBe(0); + return result.stdout.trim(); +} + +function skipArtifactDownload(commands) { + return commands.replace(/^gh run download .*$/m, "# artifact already downloaded"); +} + describe("create_pull_request_helpers - constants", () => { it("MANAGED_FALLBACK_ISSUE_LABEL is the correct triage label", () => { expect(MANAGED_FALLBACK_ISSUE_LABEL).toBe("agentic-workflows"); @@ -128,6 +150,75 @@ describe("createBundleTempRef", () => { }); }); +describe("manual branch recovery/apply commands", () => { + it("shell-quotes dynamic recovery arguments and resolves bundle heads", () => { + const commands = buildManualBranchRecoveryCommands({ + hasBundleFile: true, + runId: "123; echo injected", + artifactFileName: "agent'changes.bundle", + branchName: "feature/branch'; echo injected", + baseBranch: "main", + tempRef: "refs/bundles/tmp'; echo injected", + }); + + expect(commands).toContain("git bundle list-heads"); + expect(commands).toContain('$2 == "HEAD"'); + expect(commands).toContain("gh run download '123; echo injected' -n agent -D '/tmp/agent-123; echo injected'"); + expect(commands).toContain("bundle_path='/tmp/agent-123; echo injected/agent'\\''changes.bundle'"); + expect(commands).toContain("target_ref='refs/heads/feature/branch'\\''; echo injected'"); + expect(commands).not.toContain("refs/heads/feature/branch'; echo injected:"); + }); + + it("manual apply bundle commands work with a real HEAD-only bundle", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "manual-apply-head-bundle-")); + const artifactRunId = `head-only-${process.pid}-${Date.now()}`; + const artifactDir = path.join(os.tmpdir(), `agent-${artifactRunId}`); + + try { + const remoteDir = path.join(tempRoot, "remote.git"); + const workDir = path.join(tempRoot, "work"); + const targetDir = path.join(tempRoot, "target"); + fs.mkdirSync(artifactDir, { recursive: true }); + + runGit(["init", "--bare", remoteDir], tempRoot); + fs.mkdirSync(workDir, { recursive: true }); + runGit(["init"], workDir); + runGit(["config", "user.email", "test@example.com"], workDir); + runGit(["config", "user.name", "Test User"], workDir); + fs.writeFileSync(path.join(workDir, "file.txt"), "base\n"); + runGit(["add", "file.txt"], workDir); + runGit(["commit", "-m", "base"], workDir); + runGit(["branch", "-M", "feature"], workDir); + runGit(["remote", "add", "origin", remoteDir], workDir); + runGit(["push", "-u", "origin", "feature"], workDir); + + runGit(["clone", remoteDir, targetDir], tempRoot); + runGit(["checkout", "feature"], targetDir); + + fs.writeFileSync(path.join(workDir, "file.txt"), "updated\n"); + runGit(["commit", "-am", "update"], workDir); + runGit(["bundle", "create", path.join(artifactDir, "head-only.bundle"), "HEAD"], workDir); + + const commands = buildManualBranchApplyCommands({ + hasBundleFile: true, + runId: artifactRunId, + artifactFileName: "head-only.bundle", + branchName: "feature", + branchRemote: "origin", + }); + + expect(runGit(["bundle", "list-heads", path.join(artifactDir, "head-only.bundle")], targetDir)).toMatch(/\sHEAD$/); + runShell(skipArtifactDownload(commands), targetDir); + + expect(fs.readFileSync(path.join(targetDir, "file.txt"), "utf8")).toBe("updated\n"); + expect(runGit(["rev-parse", "feature"], targetDir)).toBe(runGit(["--git-dir", remoteDir, "rev-parse", "feature"], tempRoot)); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(artifactDir, { recursive: true, force: true }); + } + }); +}); + // --------------------------------------------------------------------------- // isLabelTransientError // --------------------------------------------------------------------------- diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 7485b49ccba..33f022b35e2 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -816,16 +816,13 @@ async function main(config = {}) { const createProtectedFilesFallbackIssue = async files => { const runUrl = buildWorkflowRunUrl(context, context.repo); const runId = context.runId; - const artifactFileName = hasBundleFile - ? bundleFilePath.replace("/tmp/gh-aw/", "") - : 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 = buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName, branchName, + branchRemote: branchRemoteName, }); const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; const prUrl = `${githubServer}/${repoParts.owner}/${repoParts.repo}/pull/${pullNumber}`; diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index d8ff42755a4..0aa858e5b45 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -3299,6 +3299,54 @@ ${diffs} expect(issueBody).not.toContain("git am --3way"); }); + it("should use configured fork remote in protected-files manual apply instructions", async () => { + const branch = "should-use-configured-fork-remote-in-manual-apply-instr"; + createPatchFile(branch, createPatchWithFiles("package.json")); + const bundlePath = canonicalBundlePath(branch); + fs.writeFileSync(bundlePath, "bundle content"); + mockContext.payload.pull_request.head.repo = { + full_name: "fork-owner/test-repo", + fork: true, + owner: { login: "fork-owner" }, + }; + mockGithub.rest.pulls.get.mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: { + full_name: "fork-owner/test-repo", + fork: true, + }, + }, + base: { + repo: { + full_name: "test-owner/test-repo", + }, + }, + title: "Test PR", + labels: [], + }, + }); + + const module = await loadModule(); + const handler = await module.main({ + protected_files: ["package.json"], + protected_files_policy: "fallback-to-issue", + "head-repo": "fork-owner/test-repo", + allowed_repos: ["test-owner/test-repo", "fork-owner/test-repo"], + }); + const result = await handler({ branch }, {}); + + expect(result.success).toBe(true); + expect(result.fallback_used).toBe(true); + + const issueBody = mockGithub.rest.issues.create.mock.calls[0][0].body; + expect(issueBody).toContain("git fetch 'https://github.com/fork-owner/test-repo.git'"); + expect(issueBody).toContain("git push 'https://github.com/fork-owner/test-repo.git'"); + expect(issueBody).not.toContain("git fetch origin"); + expect(issueBody).not.toContain("git push origin"); + }); + it("should block a protected file when no allowed-files list is configured", async () => { const patchPath = createPatchFile("should-block-a-protected-file-when-no-allowed-files-list-is-", createPatchWithFiles("package.json")); From 002186c0fe37c86a9f1468ab80ca2274cd1b8d4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:17:03 +0000 Subject: [PATCH 4/4] Fix manual apply fork-head instructions Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../setup/js/create_pull_request_helpers.cjs | 9 ++-- .../js/create_pull_request_helpers.test.cjs | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/create_pull_request_helpers.cjs b/actions/setup/js/create_pull_request_helpers.cjs index 68db2fd70c0..0c6d94a2ae9 100644 --- a/actions/setup/js/create_pull_request_helpers.cjs +++ b/actions/setup/js/create_pull_request_helpers.cjs @@ -405,6 +405,7 @@ function buildManualBranchRecoveryCommands({ hasBundleFile, runId, artifactFileN */ 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`, @@ -413,7 +414,7 @@ function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName `# Fetch the bundle into a temporary ref, then fast-forward the branch`, `bundle_path=${shellQuote(bundlePath)}`, `git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, - `git checkout ${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 }')`, @@ -425,7 +426,7 @@ function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName `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(branchName)}`, + `git push ${shellQuote(branchRemote)} ${shellQuote(pushRef)}`, ].join("\n"); } return [ @@ -434,9 +435,9 @@ function buildManualBranchApplyCommands({ hasBundleFile, runId, artifactFileName ``, `# Apply the patch to the pull request branch`, `git fetch ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, - `git checkout ${shellQuote(branchName)}`, + `git checkout -B ${shellQuote(branchName)} FETCH_HEAD`, `git am --3way ${shellQuote(`/tmp/agent-${runId}/${artifactFileName}`)}`, - `git push ${shellQuote(branchRemote)} ${shellQuote(branchName)}`, + `git push ${shellQuote(branchRemote)} ${shellQuote(pushRef)}`, ].join("\n"); } diff --git a/actions/setup/js/create_pull_request_helpers.test.cjs b/actions/setup/js/create_pull_request_helpers.test.cjs index 303a4f9f34b..c377aa3d890 100644 --- a/actions/setup/js/create_pull_request_helpers.test.cjs +++ b/actions/setup/js/create_pull_request_helpers.test.cjs @@ -169,6 +169,46 @@ describe("manual branch recovery/apply commands", () => { expect(commands).not.toContain("refs/heads/feature/branch'; echo injected:"); }); + it("manual recovery bundle commands work with a real HEAD-only bundle", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "manual-recovery-head-bundle-")); + const artifactRunId = `head-only-recovery-${process.pid}-${Date.now()}`; + const artifactDir = path.join(os.tmpdir(), `agent-${artifactRunId}`); + + try { + const workDir = path.join(tempRoot, "work"); + const targetDir = path.join(tempRoot, "target"); + fs.mkdirSync(artifactDir, { recursive: true }); + fs.mkdirSync(workDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + runGit(["init"], workDir); + runGit(["config", "user.email", "test@example.com"], workDir); + runGit(["config", "user.name", "Test User"], workDir); + fs.writeFileSync(path.join(workDir, "file.txt"), "updated\n"); + runGit(["add", "file.txt"], workDir); + runGit(["commit", "-m", "bundle head"], workDir); + runGit(["bundle", "create", path.join(artifactDir, "head-only.bundle"), "HEAD"], workDir); + + runGit(["init"], targetDir); + const commands = buildManualBranchRecoveryCommands({ + hasBundleFile: true, + runId: artifactRunId, + artifactFileName: "head-only.bundle", + branchName: "feature/recovered", + tempRef: "refs/bundles/manual-recovery", + }); + + expect(runGit(["bundle", "list-heads", path.join(artifactDir, "head-only.bundle")], targetDir)).toMatch(/\sHEAD$/); + runShell(skipArtifactDownload(commands), targetDir); + + expect(fs.readFileSync(path.join(targetDir, "file.txt"), "utf8")).toBe("updated\n"); + expect(runGit(["rev-parse", "feature/recovered"], targetDir)).toBe(runGit(["rev-parse", "HEAD"], workDir)); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(artifactDir, { recursive: true, force: true }); + } + }); + it("manual apply bundle commands work with a real HEAD-only bundle", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "manual-apply-head-bundle-")); const artifactRunId = `head-only-${process.pid}-${Date.now()}`; @@ -207,6 +247,8 @@ describe("manual branch recovery/apply commands", () => { branchRemote: "origin", }); + expect(commands).toContain("git checkout -B 'feature' FETCH_HEAD"); + expect(commands).toContain("git push 'origin' 'HEAD:refs/heads/feature'"); expect(runGit(["bundle", "list-heads", path.join(artifactDir, "head-only.bundle")], targetDir)).toMatch(/\sHEAD$/); runShell(skipArtifactDownload(commands), targetDir);