Skip to content

Commit b036dc4

Browse files
authored
fix: resolve target repo checkout path in push_to_pull_request_branch handlers (#28377)
1 parent 8848e0f commit b036dc4

4 files changed

Lines changed: 187 additions & 29 deletions

File tree

actions/setup/js/push_to_pull_request_branch.cjs

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const { checkFileProtection } = require("./manifest_file_helpers.cjs");
1717
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
1818
const { renderTemplateFromFile, buildProtectedFileList } = require("./messages_core.cjs");
1919
const { getGitAuthEnv } = require("./git_helpers.cjs");
20+
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
2021

2122
/**
2223
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
@@ -353,7 +354,27 @@ async function main(config = {}) {
353354

354355
core.info(`Target repository: ${itemRepo}`);
355356

356-
// Fetch the specific PR to get its head branch, title, and labels
357+
// Resolve the checkout directory for the target repo.
358+
// When the target repo differs from the workflow repo, it may be checked out
359+
// into a subdirectory of GITHUB_WORKSPACE (e.g. via actions/checkout path:).
360+
// All git operations must run from that directory, not from GITHUB_WORKSPACE.
361+
let repoCwd = undefined;
362+
const workflowRepo = process.env.GITHUB_REPOSITORY || "";
363+
if (itemRepo.toLowerCase() !== workflowRepo.toLowerCase()) {
364+
core.info(`Cross-repo push: looking for checkout of ${itemRepo}`);
365+
const checkoutResult = findRepoCheckout(itemRepo, process.env.GITHUB_WORKSPACE, { allowedRepos });
366+
if (!checkoutResult.success) {
367+
return {
368+
success: false,
369+
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.`,
370+
};
371+
}
372+
repoCwd = checkoutResult.path;
373+
core.info(`Found checkout for ${itemRepo} at: ${repoCwd}`);
374+
}
375+
376+
// Base options for all git exec calls - includes cwd when running in a subdirectory checkout
377+
const baseGitOpts = repoCwd ? { cwd: repoCwd } : {};
357378
let pullRequest;
358379
try {
359380
const response = await githubClient.rest.pulls.get({
@@ -490,6 +511,7 @@ async function main(config = {}) {
490511
{
491512
const lsRemoteResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], {
492513
env: { ...process.env, ...gitAuthEnv },
514+
...baseGitOpts,
493515
ignoreReturnCode: true,
494516
});
495517

@@ -525,6 +547,7 @@ async function main(config = {}) {
525547
core.info(`Fetching branch: ${branchName}`);
526548
await exec.exec("git", ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], {
527549
env: { ...process.env, ...gitAuthEnv },
550+
...baseGitOpts,
528551
});
529552
} catch (fetchError) {
530553
const fetchErrorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
@@ -538,7 +561,7 @@ async function main(config = {}) {
538561

539562
// Check if branch exists on origin
540563
try {
541-
await exec.exec(`git rev-parse --verify origin/${branchName}`);
564+
await exec.exec(`git rev-parse --verify origin/${branchName}`, [], baseGitOpts);
542565
} catch (verifyError) {
543566
const missingBranchError = MISSING_BRANCH_ERROR_TEMPLATE(branchName);
544567
if (ignoreMissingBranchFailure) {
@@ -550,7 +573,7 @@ async function main(config = {}) {
550573

551574
// Checkout the branch from origin
552575
try {
553-
await exec.exec(`git checkout -B ${branchName} origin/${branchName}`);
576+
await exec.exec(`git checkout -B ${branchName} origin/${branchName}`, [], baseGitOpts);
554577
core.info(`Checked out existing branch from origin: ${branchName}`);
555578
} catch (checkoutError) {
556579
return { success: false, error: `Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}` };
@@ -566,7 +589,7 @@ async function main(config = {}) {
566589
if (hasChanges) {
567590
// Capture HEAD before applying changes to compute new-commit count later
568591
try {
569-
const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"]);
592+
const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts);
570593
remoteHeadBeforePatch = stdout.trim();
571594
} catch {
572595
// Non-fatal - extra empty commit will be skipped
@@ -579,24 +602,24 @@ async function main(config = {}) {
579602
const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`;
580603
try {
581604
// Fetch from bundle into a temporary ref
582-
await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`]);
605+
await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${message.branch}:${bundleRef}`], baseGitOpts);
583606
core.info(`Fetched bundle to ${bundleRef}`);
584607

585608
// Fast-forward the current branch to the bundle tip
586-
await exec.exec("git", ["merge", "--ff-only", bundleRef]);
609+
await exec.exec("git", ["merge", "--ff-only", bundleRef], baseGitOpts);
587610
core.info("Fast-forwarded branch to bundle tip");
588611

589612
// Clean up the temporary ref
590613
try {
591-
await exec.exec("git", ["update-ref", "-d", bundleRef]);
614+
await exec.exec("git", ["update-ref", "-d", bundleRef], baseGitOpts);
592615
} catch {
593616
// Non-fatal cleanup
594617
}
595618
} catch (bundleError) {
596619
core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
597620
// Clean up temp ref if it exists
598621
try {
599-
await exec.exec("git", ["update-ref", "-d", bundleRef]);
622+
await exec.exec("git", ["update-ref", "-d", bundleRef], baseGitOpts);
600623
} catch {
601624
// Ignore
602625
}
@@ -631,7 +654,7 @@ async function main(config = {}) {
631654

632655
// Use --3way to handle cross-repo patches where the patch base may differ from target repo
633656
// This allows git to resolve create-vs-modify mismatches when a file exists in target but not source
634-
await exec.exec(`git am --3way ${patchFilePath}`);
657+
await exec.exec(`git am --3way ${patchFilePath}`, [], baseGitOpts);
635658
core.info("Patch applied successfully");
636659
} catch (error) {
637660
core.error(`Failed to apply patch: ${getErrorMessage(error)}`);
@@ -640,23 +663,23 @@ async function main(config = {}) {
640663
try {
641664
core.info("Investigating patch failure...");
642665

643-
const statusResult = await exec.getExecOutput("git", ["status"]);
666+
const statusResult = await exec.getExecOutput("git", ["status"], baseGitOpts);
644667
core.info("Git status output:");
645668
core.info(statusResult.stdout);
646669

647-
const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]);
670+
const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"], baseGitOpts);
648671
core.info("Recent commits (last 5):");
649672
core.info(logResult.stdout);
650673

651-
const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]);
674+
const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"], baseGitOpts);
652675
core.info("Uncommitted changes:");
653676
core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)");
654677

655-
const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]);
678+
const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"], baseGitOpts);
656679
core.info("Failed patch diff:");
657680
core.info(patchDiffResult.stdout);
658681

659-
const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]);
682+
const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"], baseGitOpts);
660683
core.info("Failed patch (full):");
661684
core.info(patchFullResult.stdout);
662685
} catch (investigateError) {
@@ -679,12 +702,13 @@ async function main(config = {}) {
679702
const reviewBranchName = normalizeBranchName(`${branchName}-review`, String(Date.now()));
680703
try {
681704
// Rename current local branch to review branch
682-
await exec.exec("git", ["checkout", "-b", reviewBranchName]);
705+
await exec.exec("git", ["checkout", "-b", reviewBranchName], baseGitOpts);
683706
core.info(`Created review branch: ${reviewBranchName}`);
684707

685708
// Push the review branch
686709
await exec.exec("git", ["push", "origin", reviewBranchName], {
687710
env: { ...process.env, ...gitAuthEnv },
711+
...baseGitOpts,
688712
});
689713
core.info(`Pushed review branch: ${reviewBranchName}`);
690714

@@ -750,7 +774,7 @@ async function main(config = {}) {
750774
repo: repoParts.repo,
751775
branch: branchName,
752776
baseRef: remoteHeadBeforePatch || `origin/${branchName}`,
753-
cwd: process.cwd(),
777+
...baseGitOpts,
754778
gitAuthEnv,
755779
});
756780
if (pushedSha) {
@@ -771,6 +795,7 @@ async function main(config = {}) {
771795
try {
772796
const lsRemoteAfterPushResult = await exec.getExecOutput("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], {
773797
env: { ...process.env, ...gitAuthEnv },
798+
...baseGitOpts,
774799
ignoreReturnCode: true,
775800
});
776801

@@ -790,9 +815,10 @@ async function main(config = {}) {
790815
const fallbackBranchName = normalizeBranchName(`${branchName}-fallback`, String(Date.now()));
791816
core.warning(`Non-fast-forward push detected; creating fallback pull request from '${fallbackBranchName}' to '${branchName}'`);
792817
try {
793-
await exec.exec("git", ["checkout", "-b", fallbackBranchName]);
818+
await exec.exec("git", ["checkout", "-b", fallbackBranchName], baseGitOpts);
794819
await exec.exec("git", ["push", "origin", fallbackBranchName], {
795820
env: { ...process.env, ...gitAuthEnv },
821+
...baseGitOpts,
796822
});
797823

798824
const fallbackBody = [
@@ -842,7 +868,7 @@ async function main(config = {}) {
842868
// Count new commits pushed for the CI trigger decision
843869
if (remoteHeadBeforePatch) {
844870
try {
845-
const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`]);
871+
const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `${remoteHeadBeforePatch}..HEAD`], baseGitOpts);
846872
newCommitCount = parseInt(countStr.trim(), 10);
847873
core.info(`${newCommitCount} new commit(s) pushed to branch`);
848874
} catch {
@@ -872,7 +898,7 @@ async function main(config = {}) {
872898
// Fall back to local HEAD only if the helper did not return one.
873899
let commitSha = pushedCommitSha;
874900
if (!commitSha) {
875-
const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]);
901+
const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"], baseGitOpts);
876902
if (commitShaRes.exitCode !== 0) {
877903
return { success: false, error: "Failed to get commit SHA" };
878904
}

actions/setup/js/push_to_pull_request_branch.test.cjs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,89 @@ ${diffs}
16661666
expect(result.error || "").not.toContain("outside the allowed-files list");
16671667
});
16681668
});
1669+
1670+
// ──────────────────────────────────────────────────────
1671+
// Cross-Repo Checkout Scenarios
1672+
// ──────────────────────────────────────────────────────
1673+
1674+
describe("cross-repo checkout", () => {
1675+
it("should return error when target repo differs from workflow repo and is not found in workspace", async () => {
1676+
// GITHUB_REPOSITORY is set to "test-owner/test-repo" in beforeEach
1677+
// Targeting "other-owner/other-repo" - different repo, not checked out
1678+
mockGithub.rest.pulls.get = vi.fn().mockResolvedValue({
1679+
data: {
1680+
head: {
1681+
ref: "feature-branch",
1682+
repo: { full_name: "other-owner/other-repo", fork: false, owner: { login: "other-owner" } },
1683+
},
1684+
base: {
1685+
repo: { full_name: "other-owner/other-repo", owner: { login: "other-owner" } },
1686+
},
1687+
title: "Cross-repo PR",
1688+
labels: [],
1689+
},
1690+
});
1691+
1692+
const patchPath = createPatchFile();
1693+
const module = await loadModule();
1694+
const handler = await module.main({ "target-repo": "other-owner/other-repo" });
1695+
1696+
const result = await handler({ patch_path: patchPath, pull_request_number: 42 }, {});
1697+
1698+
expect(result.success).toBe(false);
1699+
expect(result.error).toContain("other-owner/other-repo");
1700+
expect(result.error).toContain("not found in workspace");
1701+
});
1702+
1703+
it("should pass cwd to git commands when target repo is checked out in a subdirectory", async () => {
1704+
// Create a subdirectory checkout with a remote that matches "other-owner/other-repo"
1705+
const subRepoDir = path.join(tempDir, "other-repo");
1706+
fs.mkdirSync(subRepoDir, { recursive: true });
1707+
const { execSync } = await import("child_process");
1708+
execSync("git init -b main", { cwd: subRepoDir, stdio: "pipe" });
1709+
execSync("git config user.email 'test@example.com'", { cwd: subRepoDir, stdio: "pipe" });
1710+
execSync("git config user.name 'Test User'", { cwd: subRepoDir, stdio: "pipe" });
1711+
execSync("git remote add origin https://github.com/other-owner/other-repo.git", { cwd: subRepoDir, stdio: "pipe" });
1712+
1713+
// Set workspace to tempDir so findRepoCheckout scans it
1714+
process.env.GITHUB_WORKSPACE = tempDir;
1715+
1716+
mockGithub.rest.pulls.get = vi.fn().mockResolvedValue({
1717+
data: {
1718+
head: {
1719+
ref: "feature-branch",
1720+
repo: { full_name: "other-owner/other-repo", fork: false, owner: { login: "other-owner" } },
1721+
},
1722+
base: {
1723+
repo: { full_name: "other-owner/other-repo", owner: { login: "other-owner" } },
1724+
},
1725+
title: "Cross-repo PR",
1726+
labels: [],
1727+
},
1728+
});
1729+
mockGithub.rest.repos.get = vi.fn().mockResolvedValue({ data: { default_branch: "main" } });
1730+
mockGithub.rest.repos.getBranchProtection = vi.fn().mockRejectedValue(Object.assign(new Error("not protected"), { status: 404 }));
1731+
1732+
mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" });
1733+
1734+
const patchPath = createPatchFile();
1735+
const module = await loadModule();
1736+
const handler = await module.main({ "target-repo": "other-owner/other-repo" });
1737+
1738+
await handler({ patch_path: patchPath, pull_request_number: 42 }, {});
1739+
1740+
// Verify git ls-remote was called with cwd pointing at the subdirectory
1741+
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining(`Found checkout for other-owner/other-repo at: ${subRepoDir}`));
1742+
1743+
// Verify at least one exec call received cwd pointing at the subdirectory
1744+
const allExecCalls = [...mockExec.exec.mock.calls, ...mockExec.getExecOutput.mock.calls];
1745+
const cwdCalls = allExecCalls.filter(call => {
1746+
const opts = call.find(arg => arg && typeof arg === "object" && !Array.isArray(arg) && "cwd" in arg);
1747+
return opts && opts.cwd === subRepoDir;
1748+
});
1749+
expect(cwdCalls.length).toBeGreaterThan(0);
1750+
});
1751+
});
16691752
});
16701753

16711754
// ──────────────────────────────────────────────────────

actions/setup/js/safe_outputs_handlers.cjs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -486,22 +486,23 @@ function createHandlers(server, appendSafeOutput, config = {}) {
486486
// Get base branch for the resolved target repository
487487
const baseBranch = await getBaseBranch(repoParts);
488488

489-
// Determine the working directory for git operations
490-
// If repo is specified, find where it's checked out
489+
// Determine the working directory for git operations.
490+
// Look up the checkout path when the target repo is explicitly provided by the agent
491+
// or explicitly configured via target-repo in the workflow config — this ensures patch
492+
// generation runs from the correct directory when the target repo is checked out in a subdirectory.
491493
let repoCwd = null;
492-
if (entry.repo && entry.repo.trim()) {
493-
const repoSlug = repoResult.repo;
494-
const checkoutResult = findRepoCheckout(repoSlug);
494+
const itemRepo = repoResult.repo;
495+
if ((entry.repo && entry.repo.trim()) || pushConfig["target-repo"]) {
496+
server.debug(`Looking for checkout of target repo: ${itemRepo}`);
497+
const checkoutResult = findRepoCheckout(itemRepo);
495498
if (!checkoutResult.success) {
496499
return {
497500
content: [
498501
{
499502
type: "text",
500503
text: JSON.stringify({
501504
result: "error",
502-
error:
503-
`Repository checkout not found for ${repoSlug}. Ensure the repository is checked out in this workflow using actions/checkout. ` +
504-
"If checking out multiple repositories, use the 'path' input so the checkout can be located.",
505+
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.`,
505506
}),
506507
},
507508
],
@@ -510,7 +511,7 @@ function createHandlers(server, appendSafeOutput, config = {}) {
510511
}
511512
repoCwd = checkoutResult.path;
512513
entry.repo_cwd = repoCwd;
513-
server.debug(`Selected checkout folder for ${repoSlug}: ${repoCwd}`);
514+
server.debug(`Selected checkout folder for ${itemRepo}: ${repoCwd}`);
514515
}
515516

516517
// If branch is not provided, is empty, or equals the base branch, use the current branch from git

0 commit comments

Comments
 (0)