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
64 changes: 45 additions & 19 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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)}` };
Expand All @@ -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
Expand All @@ -579,24 +602,24 @@ 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
}
} catch (bundleError) {
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
}
Expand Down Expand Up @@ -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)}`);
Expand All @@ -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) {
Expand All @@ -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}`);

Expand Down Expand Up @@ -750,7 +774,7 @@ async function main(config = {}) {
repo: repoParts.repo,
branch: branchName,
baseRef: remoteHeadBeforePatch || `origin/${branchName}`,
cwd: process.cwd(),
...baseGitOpts,
gitAuthEnv,
});
if (pushedSha) {
Expand All @@ -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,
});

Expand All @@ -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 = [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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" };
}
Expand Down
83 changes: 83 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});

// ──────────────────────────────────────────────────────
Expand Down
19 changes: 10 additions & 9 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -486,22 +486,23 @@ 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: [
{
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.`,
}),
},
],
Expand All @@ -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
Expand Down
Loading