diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 41f19229181..91b2550238f 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -17,6 +17,7 @@ const { checkFileProtection } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplateFromFile, buildProtectedFileList } = require("./messages_core.cjs"); const { getGitAuthEnv } = require("./git_helpers.cjs"); +const { findRepoCheckout } = require("./find_repo_checkout.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -353,7 +354,27 @@ async function main(config = {}) { core.info(`Target repository: ${itemRepo}`); - // Fetch the specific PR to get its head branch, title, and labels + // Resolve the checkout directory for the target repo. + // When the target repo differs from the workflow repo, it may be checked out + // into a subdirectory of GITHUB_WORKSPACE (e.g. via actions/checkout path:). + // All git operations must run from that directory, not from GITHUB_WORKSPACE. + let repoCwd = undefined; + const workflowRepo = process.env.GITHUB_REPOSITORY || ""; + if (itemRepo.toLowerCase() !== workflowRepo.toLowerCase()) { + core.info(`Cross-repo push: looking for checkout of ${itemRepo}`); + const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos }); + if (!checkoutResult.success) { + return { + success: false, + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`, + }; + } + repoCwd = checkoutResult.path; + core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`); + } + + // Base options for all git exec calls - includes cwd when running in a subdirectory checkout + const baseGitOpts = repoCwd ? { cwd: repoCwd } : {}; let pullRequest; try { const response = await githubClient.rest.pulls.get({ @@ -490,6 +511,7 @@ async function main(config = {}) { { const lsRemoteResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, ignoreReturnCode: true, }); @@ -525,6 +547,7 @@ async function main(config = {}) { core.info(`Fetching branch: ${branchName}`); await exec.exec("git", ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], { env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, }); } catch (fetchError) { const fetchErrorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError); @@ -538,7 +561,7 @@ async function main(config = {}) { // Check if branch exists on origin try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); + await exec.exec(`git rev-parse --verify origin/${branchName}`, [], baseGitOpts); } catch (verifyError) { const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName); if (ignoreMissingBranchFailure) { @@ -550,7 +573,7 @@ async function main(config = {}) { // Checkout the branch from origin try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); + await exec.exec(`git checkout -B ${branchName} origin/${branchName}`, [], baseGitOpts); core.info(`Checked out existing branch from origin: ${branchName}`); } catch (checkoutError) { return { success: false, error: `Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}` }; @@ -566,7 +589,7 @@ async function main(config = {}) { if (hasChanges) { // Capture HEAD before applying changes to compute new-commit count later try { - const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); + const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts); remoteHeadBeforePatch = stdout.trim(); } catch { // Non-fatal - extra empty commit will be skipped @@ -579,16 +602,16 @@ async function main(config = {}) { const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`; try { // Fetch from bundle into a temporary ref - await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`]); + await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`], baseGitOpts); core.info(`Fetched bundle to ${bundleRef}`); // Fast-forward the current branch to the bundle tip - await exec.exec("git", ["merge", "--ff-only", bundleRef]); + await exec.exec("git", ["merge", "--ff-only", bundleRef], baseGitOpts); core.info("Fast-forwarded branch to bundle tip"); // Clean up the temporary ref try { - await exec.exec("git", ["update-ref", "-d", bundleRef]); + await exec.exec("git", ["update-ref", "-d", bundleRef], baseGitOpts); } catch { // Non-fatal cleanup } @@ -596,7 +619,7 @@ async function main(config = {}) { core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`); // Clean up temp ref if it exists try { - await exec.exec("git", ["update-ref", "-d", bundleRef]); + await exec.exec("git", ["update-ref", "-d", bundleRef], baseGitOpts); } catch { // Ignore } @@ -631,7 +654,7 @@ async function main(config = {}) { // Use --3way to handle cross-repo patches where the patch base may differ from target repo // This allows git to resolve create-vs-modify mismatches when a file exists in target but not source - await exec.exec(`git am --3way ${patchFilePath}`); + await exec.exec(`git am --3way ${patchFilePath}`, [], baseGitOpts); core.info("Patch applied successfully"); } catch (error) { core.error(`Failed to apply patch: ${getErrorMessage(error)}`); @@ -640,23 +663,23 @@ async function main(config = {}) { try { core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); + const statusResult = await exec.getExecOutput("git", ["status"], baseGitOpts); core.info("Git status output:"); core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); + const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"], baseGitOpts); core.info("Recent commits (last 5):"); core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); + const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"], baseGitOpts); core.info("Uncommitted changes:"); core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); + const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"], baseGitOpts); core.info("Failed patch diff:"); core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); + const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"], baseGitOpts); core.info("Failed patch (full):"); core.info(patchFullResult.stdout); } catch (investigateError) { @@ -679,12 +702,13 @@ async function main(config = {}) { const reviewBranchName = normalizeBranchName(`${branchName}-review`, String(Date.now())); try { // Rename current local branch to review branch - await exec.exec("git", ["checkout", "-b", reviewBranchName]); + await exec.exec("git", ["checkout", "-b", reviewBranchName], baseGitOpts); core.info(`Created review branch: ${reviewBranchName}`); // Push the review branch await exec.exec("git", ["push", "origin", reviewBranchName], { env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, }); core.info(`Pushed review branch: ${reviewBranchName}`); @@ -750,7 +774,7 @@ async function main(config = {}) { repo: repoParts.repo, branch: branchName, baseRef: remoteHeadBeforePatch || `origin/${branchName}`, - cwd: process.cwd(), + ...baseGitOpts, gitAuthEnv, }); if (pushedSha) { @@ -771,6 +795,7 @@ async function main(config = {}) { try { const lsRemoteAfterPushResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, ignoreReturnCode: true, }); @@ -790,9 +815,10 @@ async function main(config = {}) { 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", ["checkout", "-b", fallbackBranchName], baseGitOpts); await exec.exec("git", ["push", "origin", fallbackBranchName], { env: { ...process.env, ...gitAuthEnv }, + ...baseGitOpts, }); const fallbackBody = [ @@ -842,7 +868,7 @@ async function main(config = {}) { // Count new commits pushed for the CI trigger decision if (remoteHeadBeforePatch) { try { - const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`]); + const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`], baseGitOpts); newCommitCount = parseInt(countStr.trim(), 10); core.info(`${newCommitCount} new commit(s) pushed to branch`); } catch { @@ -872,7 +898,7 @@ async function main(config = {}) { // Fall back to local HEAD only if the helper did not return one. let commitSha = pushedCommitSha; if (!commitSha) { - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); + const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts); if (commitShaRes.exitCode !== 0) { return { success: false, error: "Failed to get commit SHA" }; } 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 4d5ea8fa604..369c304a88e 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1666,6 +1666,89 @@ ${diffs} expect(result.error || "").not.toContain("outside the allowed-files list"); }); }); + + // ────────────────────────────────────────────────────── + // Cross-Repo Checkout Scenarios + // ────────────────────────────────────────────────────── + + describe("cross-repo checkout", () => { + it("should return error when target repo differs from workflow repo and is not found in workspace", async () => { + // GITHUB_REPOSITORY is set to "test-owner/test-repo" in beforeEach + // Targeting "other-owner/other-repo" - different repo, not checked out + mockGithub.rest.pulls.get = vi.fn().mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: { full_name: "other-owner/other-repo", fork: false, owner: { login: "other-owner" } }, + }, + base: { + repo: { full_name: "other-owner/other-repo", owner: { login: "other-owner" } }, + }, + title: "Cross-repo PR", + labels: [], + }, + }); + + const patchPath = createPatchFile(); + const module = await loadModule(); + const handler = await module.main({ "target-repo": "other-owner/other-repo" }); + + const result = await handler({ patch_path: patchPath, pull_request_number: 42 }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("other-owner/other-repo"); + expect(result.error).toContain("not found in workspace"); + }); + + it("should pass cwd to git commands when target repo is checked out in a subdirectory", async () => { + // Create a subdirectory checkout with a remote that matches "other-owner/other-repo" + const subRepoDir = path.join(tempDir, "other-repo"); + fs.mkdirSync(subRepoDir, { recursive: true }); + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: subRepoDir, stdio: "pipe" }); + execSync("git config user.email 'test@example.com'", { cwd: subRepoDir, stdio: "pipe" }); + execSync("git config user.name 'Test User'", { cwd: subRepoDir, stdio: "pipe" }); + execSync("git remote add origin https://github.com/other-owner/other-repo.git", { cwd: subRepoDir, stdio: "pipe" }); + + // Set workspace to tempDir so findRepoCheckout scans it + process.env.GITHUB_WORKSPACE = tempDir; + + mockGithub.rest.pulls.get = vi.fn().mockResolvedValue({ + data: { + head: { + ref: "feature-branch", + repo: { full_name: "other-owner/other-repo", fork: false, owner: { login: "other-owner" } }, + }, + base: { + repo: { full_name: "other-owner/other-repo", owner: { login: "other-owner" } }, + }, + title: "Cross-repo PR", + labels: [], + }, + }); + mockGithub.rest.repos.get = vi.fn().mockResolvedValue({ data: { default_branch: "main" } }); + mockGithub.rest.repos.getBranchProtection = vi.fn().mockRejectedValue(Object.assign(new Error("not protected"), { status: 404 })); + + mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + + const patchPath = createPatchFile(); + const module = await loadModule(); + const handler = await module.main({ "target-repo": "other-owner/other-repo" }); + + await handler({ patch_path: patchPath, pull_request_number: 42 }, {}); + + // Verify git ls-remote was called with cwd pointing at the subdirectory + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining(`Found checkout for other-owner/other-repo at: ${subRepoDir}`)); + + // Verify at least one exec call received cwd pointing at the subdirectory + const allExecCalls = [...mockExec.exec.mock.calls, ...mockExec.getExecOutput.mock.calls]; + const cwdCalls = allExecCalls.filter(call => { + const opts = call.find(arg => arg && typeof arg === "object" && !Array.isArray(arg) && "cwd" in arg); + return opts && opts.cwd === subRepoDir; + }); + expect(cwdCalls.length).toBeGreaterThan(0); + }); + }); }); // ────────────────────────────────────────────────────── diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 02bba1a83d8..897c676d15d 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -486,12 +486,15 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Get base branch for the resolved target repository const baseBranch = await getBaseBranch(repoParts); - // Determine the working directory for git operations - // If repo is specified, find where it's checked out + // Determine the working directory for git operations. + // Look up the checkout path when the target repo is explicitly provided by the agent + // or explicitly configured via target-repo in the workflow config — this ensures patch + // generation runs from the correct directory when the target repo is checked out in a subdirectory. let repoCwd = null; - if (entry.repo && entry.repo.trim()) { - const repoSlug = repoResult.repo; - const checkoutResult = findRepoCheckout(repoSlug); + const itemRepo = repoResult.repo; + if ((entry.repo && entry.repo.trim()) || pushConfig["target-repo"]) { + server.debug(`Looking for checkout of target repo: ${itemRepo}`); + const checkoutResult = findRepoCheckout(itemRepo); if (!checkoutResult.success) { return { content: [ @@ -499,9 +502,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { type: "text", text: JSON.stringify({ result: "error", - error: - `Repository checkout not found for ${repoSlug}. Ensure the repository is checked out in this workflow using actions/checkout. ` + - "If checking out multiple repositories, use the 'path' input so the checkout can be located.", + error: `Repository '${itemRepo}' not found in workspace. Check out the target repo with actions/checkout and set its 'path' input so the checkout can be located. If checking out multiple repositories, ensure each actions/checkout step uses the appropriate 'path' input.`, }), }, ], @@ -510,7 +511,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { } repoCwd = checkoutResult.path; entry.repo_cwd = repoCwd; - server.debug(`Selected checkout folder for ${repoSlug}: ${repoCwd}`); + server.debug(`Selected checkout folder for ${itemRepo}: ${repoCwd}`); } // If branch is not provided, is empty, or equals the base branch, use the current branch from git diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 8a055023362..09332830b67 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -675,11 +675,59 @@ describe("safe_outputs_handlers", () => { expect(result.isError).toBe(true); const responseData = JSON.parse(result.content[0].text); expect(responseData.result).toBe("error"); - expect(responseData.error).toContain("Repository checkout not found for test-owner/test-repo"); + expect(responseData.error).toContain("Repository 'test-owner/test-repo' not found in workspace"); expect(responseData.error).toContain("actions/checkout"); expect(responseData.error).toContain("'path' input"); }); + it("should return error when configured target-repo checkout is not found and entry.repo is not set", async () => { + const configWithTarget = { + push_to_pull_request_branch: { "target-repo": "test-owner/test-repo" }, + }; + const handlersWithTarget = createHandlers(mockServer, mockAppendSafeOutput, configWithTarget); + + const result = await handlersWithTarget.pushToPullRequestBranchHandler({ + branch: "feature/test-change", + }); + + expect(result.isError).toBe(true); + const responseData = JSON.parse(result.content[0].text); + expect(responseData.result).toBe("error"); + expect(responseData.error).toContain("Repository 'test-owner/test-repo' not found in workspace"); + expect(responseData.error).toContain("actions/checkout"); + expect(responseData.error).toContain("'path' input"); + }); + + it("should detect branch from defaultTargetRepo checkout when entry.repo is not provided", async () => { + const { targetRepoDir } = createSideRepoWithTrackedAndLocalCommits(); + + const configWithTarget = { + push_to_pull_request_branch: { "target-repo": "test-owner/test-repo" }, + }; + const handlersWithTarget = createHandlers(mockServer, mockAppendSafeOutput, configWithTarget); + + process.env.GITHUB_BASE_REF = "main"; + try { + const result = await handlersWithTarget.pushToPullRequestBranchHandler({ + branch: "main", + }); + + expect(result.isError).toBeFalsy(); + expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("Looking for checkout of target repo: test-owner/test-repo")); + expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining(`Selected checkout folder for test-owner/test-repo: ${targetRepoDir}`)); + expect(mockServer.debug).toHaveBeenCalledWith(expect.stringContaining("detecting actual working branch: feature/test-change")); + expect(mockAppendSafeOutput).toHaveBeenCalledWith( + expect.objectContaining({ + type: "push_to_pull_request_branch", + branch: "feature/test-change", + repo_cwd: targetRepoDir, + }) + ); + } finally { + delete process.env.GITHUB_BASE_REF; + } + }); + it("should detect branch from the checked out target repo when repo is provided", async () => { const { targetRepoDir } = createSideRepoWithTrackedAndLocalCommits();