diff --git a/.changeset/patch-explicit-bundle-history-recovery.md b/.changeset/patch-explicit-bundle-history-recovery.md new file mode 100644 index 00000000000..55da4a2cbbc --- /dev/null +++ b/.changeset/patch-explicit-bundle-history-recovery.md @@ -0,0 +1,5 @@ +--- +"gh-aw": major +--- + +**Breaking change:** Pull request bundle handlers no longer fetch missing prerequisites automatically. Provision the required bundle objects during authenticated checkout, or set `allow-full-history-fetch: true` on the specific `create-pull-request` or `push-to-pull-request-branch` handler to authorize prerequisite recovery. The opt-in permits potentially unbounded prerequisite downloads; bundle import and validation remain offline. diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 9c3e5ffabe8..660025e0126 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,6 +41,6 @@ jobs: # zizmor: ignore[github_action_from_unverified_creator_used] uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - name: Install Go language server (gopls) - run: go install golang.org/x/tools/gopls@latest + run: go install golang.org/x/tools/gopls@v0.23.0 - name: Install TypeScript language server run: npm install -g typescript-language-server typescript diff --git a/actions/setup/js/bundle_transport.integration.test.cjs b/actions/setup/js/bundle_transport.integration.test.cjs new file mode 100644 index 00000000000..97943f006b2 --- /dev/null +++ b/actions/setup/js/bundle_transport.integration.test.cjs @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { spawn, spawnSync } from "child_process"; +import fs from "fs"; +import http from "http"; +import os from "os"; +import path from "path"; + +const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions } = require("./git_helpers.cjs"); +const { resolveTrustedBaseTip, getBundleValidationBase, getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs"); +const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); + +const gitEnv = () => { + const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }; + for (const key of Object.keys(env)) { + if (key.startsWith("GIT_CONFIG_") && key !== "GIT_CONFIG_GLOBAL" && key !== "GIT_CONFIG_NOSYSTEM") delete env[key]; + } + return env; +}; + +function git(cwd, args, env = gitEnv()) { + const result = spawnSync("git", args, { cwd, env, encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr); + return result.stdout.trim(); +} + +function execApi(cwd, { emulateOldGit = false } = {}) { + const calls = []; + const api = { + calls, + async getExecOutput(command, args, options = {}) { + calls.push(args); + const env = { ...gitEnv(), ...options.env }; + // Exercise the transport guard without relying on GIT_NO_LAZY_FETCH support. + if (emulateOldGit) delete env.GIT_NO_LAZY_FETCH; + const result = await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: options.cwd || cwd, env }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", data => (stdout += data)); + child.stderr.on("data", data => (stderr += data)); + child.on("error", reject); + child.on("close", exitCode => resolve({ exitCode, stdout, stderr })); + }); + if (result.exitCode !== 0 && !options.ignoreReturnCode) throw new Error(result.stderr); + return result; + }, + async exec(command, args, options = {}) { + return (await api.getExecOutput(command, args, options)).exitCode; + }, + }; + return api; +} + +function packInventory(repo) { + const dir = path.join(repo, ".git/objects/pack"); + return fs + .readdirSync(dir) + .sort() + .map(file => [file, fs.statSync(path.join(dir, file)).size]); +} + +describe("offline bundle transport with real Git", () => { + let root; + let source; + let originalCore; + let server; + + beforeEach(() => { + originalCore = global.core; + global.core = { debug: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + root = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-offline-bundle-")); + source = path.join(root, "source"); + fs.mkdirSync(source); + git(source, ["init", "-b", "main"]); + git(source, ["config", "user.name", "Bundle Test"]); + git(source, ["config", "user.email", "bundle@example.com"]); + fs.writeFileSync(path.join(source, "package.json"), "{}\n"); + fs.writeFileSync(path.join(source, "shared.txt"), "original\n"); + git(source, ["add", "."]); + git(source, ["commit", "-m", "initial"]); + }); + + afterEach(async () => { + if (server) { + await new Promise(resolve => server.close(resolve)); + server = undefined; + } + global.core = originalCore; + fs.rmSync(root, { recursive: true, force: true }); + }); + + function addCommit(file, content) { + fs.writeFileSync(path.join(source, file), content); + git(source, ["add", "."]); + git(source, ["commit", "-m", file]); + return git(source, ["rev-parse", "HEAD"]); + } + + function mergeFixture() { + git(source, ["checkout", "-b", "feature"]); + const oldHead = addCommit("shared.txt", "PR version\n"); + git(source, ["checkout", "main"]); + const trustedBase = addCommit("shared.txt", "upstream version\n"); + git(source, ["checkout", "feature"]); + const merge = spawnSync("git", ["merge", "main"], { cwd: source, env: gitEnv(), encoding: "utf8" }); + expect(merge.status).toBe(1); + const candidate = addCommit("shared.txt", "agent resolution\n"); + return { oldHead, trustedBase, candidate }; + } + + it.each([false, true])("does not lazily transfer blobs while inspecting a partial clone (old Git guard: %s)", async emulateOldGit => { + const { oldHead, trustedBase, candidate } = mergeFixture(); + git(source, ["config", "uploadpack.allowFilter", "true"]); + const partial = path.join(root, "partial"); + git(root, ["clone", "--no-checkout", "--filter=blob:none", `file://${source}`, partial]); + expect(git(partial, ["config", "--get", "remote.origin.promisor"])).toBe("true"); + const missingBlob = git(source, ["rev-parse", `${oldHead}:shared.txt`]); + const exec = execApi(partial, { emulateOldGit }); + const before = packInventory(partial); + expect(await resolveTrustedBaseTip(exec, {}, "main")).toBe(trustedBase); + expect(await getBundleValidationBase(exec, {}, oldHead, candidate, trustedBase)).toEqual({ ref: oldHead, conflictedFiles: [] }); + const absent = await exec.getExecOutput("git", ["cat-file", "-e", missingBlob], { ...offlineGitOptions(), ignoreReturnCode: true }); + expect(absent.exitCode).not.toBe(0); + expect(packInventory(partial)).toEqual(before); + expect(exec.calls.some(args => args[0] === "fetch")).toBe(false); + }); + + it.each([false, true])("fails without unshallowing when bundle prerequisites are absent (filter support: %s)", async allowFilter => { + const prerequisite = git(source, ["rev-parse", "HEAD"]); + const bundle = path.join(root, "incremental.bundle"); + addCommit("agent.txt", "agent\n"); + git(source, ["bundle", "create", bundle, `${prerequisite}..HEAD`]); + addCommit("later.txt", "later\n"); + git(source, ["config", "uploadpack.allowFilter", String(allowFilter)]); + const shallow = path.join(root, "shallow"); + git(root, ["clone", "--depth=1", "--filter=blob:none", "--no-checkout", `file://${source}`, shallow]); + const exec = execApi(shallow); + const before = packInventory(shallow); + const shallowFile = fs.readFileSync(path.join(shallow, ".git/shallow"), "utf8"); + await expect(ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle })).rejects.toThrow("allow-full-history-fetch: true"); + await expect(importLocalBundle(exec, bundle, "HEAD", "refs/bundles/test")).rejects.toThrow("allow-full-history-fetch: true"); + expect(exec.calls.some(args => args[0] === "fetch")).toBe(false); + expect(packInventory(shallow)).toEqual(before); + expect(fs.readFileSync(path.join(shallow, ".git/shallow"), "utf8")).toBe(shallowFile); + }); + + it("imports an incremental bundle into a shallow checkout with locally provisioned prerequisites", async () => { + const prerequisite = git(source, ["rev-parse", "HEAD"]); + const shallow = path.join(root, "shallow"); + git(root, ["clone", "--depth=1", `file://${source}`, shallow]); + git(source, ["checkout", "-b", "feature"]); + const candidate = addCommit("agent.txt", "agent\n"); + const bundle = path.join(root, "agent.bundle"); + git(source, ["bundle", "create", bundle, "feature", `^${prerequisite}`]); + const exec = execApi(shallow); + await ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle }); + await importLocalBundle(exec, bundle, "refs/heads/feature", "refs/bundles/test"); + expect(git(shallow, ["rev-parse", "refs/bundles/test"])).toBe(candidate); + expect(await getBundlePreApplyFiles(exec, {}, prerequisite, "refs/bundles/test")).toEqual(["agent.txt"]); + expect(git(shallow, ["rev-parse", "--is-shallow-repository"])).toBe("true"); + expect(exec.calls.some(args => args[0] === "fetch")).toBe(false); + }); + + it("retains protected-file checks when shallow history prevents a trusted baseline", async () => { + const { oldHead, trustedBase } = mergeFixture(); + addCommit("package.json", '{"agent":true}\n'); + const shallow = path.join(root, "shallow"); + git(root, ["clone", "--no-checkout", "--depth=1", `file://${source}`, shallow]); + // Provision endpoint commits, but not their common history. + git(shallow, ["fetch", "--depth=1", "origin", oldHead, trustedBase]); + const exec = execApi(shallow); + const before = packInventory(shallow); + const baseline = await getBundleValidationBase(exec, {}, oldHead, "HEAD", trustedBase); + expect(baseline).toEqual({ ref: oldHead, conflictedFiles: [] }); + const files = await getBundlePreApplyFiles(exec, {}, baseline.ref, "HEAD"); + expect(checkFileProtectionPostApply(files, { protected_files: ["package.json"] }).action).toBe("deny"); + expect(packInventory(shallow)).toEqual(before); + }); + + it("allows full history recovery only with an explicit opt-in", async () => { + const prerequisite = git(source, ["rev-parse", "HEAD"]); + const bundle = path.join(root, "agent.bundle"); + addCommit("agent.txt", "agent\n"); + git(source, ["bundle", "create", bundle, `${prerequisite}..HEAD`]); + addCommit("later.txt", "later\n"); + const shallow = path.join(root, "shallow"); + git(root, ["clone", "--depth=1", `file://${source}`, shallow]); + const exec = execApi(shallow); + await ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle, allowFullHistoryFetch: true }); + expect(exec.calls).toContainEqual(["fetch", "--unshallow", "origin"]); + expect(git(shallow, ["rev-parse", "--is-shallow-repository"])).toBe("false"); + await importLocalBundle(exec, bundle, "HEAD", "refs/bundles/test"); + }); + + it.each([false, true])("recovers fork-only prerequisites from the selected head remote (shallow target: %s)", async shallowTarget => { + const fork = path.join(root, "fork"); + git(root, ["clone", source, fork]); + git(fork, ["config", "user.name", "Bundle Test"]); + git(fork, ["config", "user.email", "bundle@example.com"]); + git(fork, ["checkout", "-b", "feature"]); + fs.writeFileSync(path.join(fork, "fork.txt"), "fork-only prerequisite\n"); + git(fork, ["add", "."]); + git(fork, ["commit", "-m", "fork-only"]); + const prerequisite = git(fork, ["rev-parse", "HEAD"]); + fs.writeFileSync(path.join(fork, "agent.txt"), "agent\n"); + git(fork, ["add", "."]); + git(fork, ["commit", "-m", "agent"]); + const candidate = git(fork, ["rev-parse", "HEAD"]); + const bundle = path.join(root, "fork.bundle"); + git(fork, ["bundle", "create", bundle, "feature", `^${prerequisite}`]); + git(fork, ["reset", "--hard", prerequisite]); + // The candidate exists only in the bundle; its prerequisite only in the fork. + const target = path.join(root, "target"); + git(root, ["clone", ...(shallowTarget ? ["--depth=1"] : []), `file://${source}`, target]); + const exec = execApi(target); + const remote = `file://${fork}`; + await expect(ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle, remote })).rejects.toThrow("disabled by default"); + expect(exec.calls.some(args => args[0] === "fetch")).toBe(false); + await ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle, allowFullHistoryFetch: true, remote }); + expect(exec.calls.filter(args => args[0] === "fetch").every(args => args.includes(remote) && !args.includes("origin"))).toBe(true); + await importLocalBundle(exec, bundle, "refs/heads/feature", "refs/bundles/fork"); + expect(git(target, ["rev-parse", "refs/bundles/fork"])).toBe(candidate); + expect(git(target, ["remote", "get-url", "origin"])).toBe(`file://${source}`); + }); + + it("uses only target-checkout credentials for metadata and falls back silently when unavailable", async () => { + const tip = git(source, ["rev-parse", "HEAD"]); + const requests = []; + let authorized = true; + const syntheticHeader = "Basic synthetic-target-checkout-credential"; + server = http.createServer((req, res) => { + requests.push({ method: req.method, url: req.url, authorized: req.headers.authorization === syntheticHeader }); + if (!authorized || req.headers.authorization !== syntheticHeader) { + res.writeHead(403); + res.end("synthetic secret-bearing remote diagnostic"); + } else if (req.url.startsWith("/target.git/info/refs")) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(`${tip}\trefs/heads/main\n`); + } else if (req.url === "/target.git/HEAD") { + res.end("ref: refs/heads/main\n"); + } else { + res.writeHead(404); + res.end(); + } + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}/target.git`; + git(source, ["remote", "add", "origin", origin]); + git(source, ["config", `http.${origin}.extraheader`, `Authorization: ${syntheticHeader}`]); + const exec = execApi(source); + const before = git(source, ["count-objects", "-v"]); + expect(await resolveTrustedBaseTip(exec, {}, "main")).toBe(tip); + expect(git(source, ["count-objects", "-v"])).toBe(before); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every(req => req.authorized && req.method === "GET" && /^\/target\.git\/(info\/refs(\?.*)?|HEAD)$/.test(req.url))).toBe(true); + authorized = false; + expect(await resolveTrustedBaseTip(exec, {}, "main")).toBe(""); + expect(JSON.stringify(global.core.warning.mock.calls)).not.toContain("synthetic"); + expect(global.core.warning).toHaveBeenCalledWith("Unable to resolve trusted PR base metadata; using PR-head validation"); + }); + + it.each([ + { exitCode: 0, stdout: "not-a-sha\trefs/heads/main\n" }, + { exitCode: 0, stdout: `${"a".repeat(40)}\trefs/heads/other\n` }, + { exitCode: 0, stdout: `${"a".repeat(40)}\trefs/heads/main\n${"b".repeat(40)}\trefs/heads/main\n` }, + { exitCode: 128, stdout: `${"a".repeat(40)}\trefs/heads/main\n` }, + ])("rejects untrusted or unsuccessful metadata results %#", async result => { + const exec = execApi(source); + const run = exec.getExecOutput; + exec.getExecOutput = vi.fn((command, args, options) => (args[0] === "ls-remote" ? Promise.resolve({ ...result, stderr: "synthetic private diagnostic" }) : run(command, args, options))); + expect(await resolveTrustedBaseTip(exec, {}, "main")).toBe(""); + expect(JSON.stringify(global.core.warning.mock.calls)).not.toContain("synthetic"); + expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "--refs", "--exit-code", "origin", "refs/heads/main"], expect.objectContaining({ silent: true })); + }); + + it("rejects invalid base refs before accessing origin", async () => { + const exec = execApi(source); + expect(await resolveTrustedBaseTip(exec, {}, "../invalid")).toBe(""); + expect(await resolveTrustedBaseTip(exec, {}, undefined)).toBe(""); + expect(exec.calls.some(args => args[0] === "ls-remote")).toBe(false); + }); + + it("reports opted-in recovery failure without exposing remote diagnostics", async () => { + const prerequisite = git(source, ["rev-parse", "HEAD"]); + addCommit("agent.txt", "agent\n"); + const bundle = path.join(root, "agent.bundle"); + git(source, ["bundle", "create", bundle, `${prerequisite}..HEAD`]); + const empty = path.join(root, "empty"); + fs.mkdirSync(empty); + git(empty, ["init"]); + const exec = execApi(empty); + const run = exec.getExecOutput; + exec.getExecOutput = vi.fn((command, args, options) => (args[0] === "fetch" ? Promise.resolve({ exitCode: 128, stdout: "", stderr: "synthetic private diagnostic" }) : run(command, args, options))); + await expect(ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle, allowFullHistoryFetch: true })).rejects.toThrow("Provision the required objects during authenticated checkout with credentials for the recovery repository."); + expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", "origin", prerequisite], expect.objectContaining({ silent: true })); + expect(JSON.stringify(global.core.warning.mock.calls)).not.toContain("synthetic"); + }); +}); diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 3c7210258c0..85efeade622 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -39,7 +39,7 @@ const { extractPatchBaseCommit } = require("./commit_sha_helpers.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { createOrUpdatePullRequest } = require("./create_or_update_pull_request.cjs"); const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); -const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, getBundlePrerequisites, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs"); +const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions, getBundlePrerequisites, linearizeRangeAsCommit } = require("./git_helpers.cjs"); const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs"); const { resolveTransportPaths } = require("./resolve_transport_paths.cjs"); const { withTransportIntegrity } = require("./transport_integrity.cjs"); @@ -223,137 +223,31 @@ function parseAutoMergeConfig(value) { /** * Apply a git bundle to a local branch without fetching directly into the branch ref. - * Fetching directly into refs/heads/ fails when that branch is currently checked out. + * Import through a temporary ref before updating a branch that may be checked out. * * @param {string} bundleFilePath - Path to the bundle file * @param {string} branchName - Target branch name * @param {string} originalAgentBranch - Original source branch name from the agent, if different * @param {{ exec: Function, getExecOutput: Function }} execApi - GitHub Actions exec API - * @param {string} [baseBranch] - Base branch name (used for iterative shallow-clone deepening) + * @param {boolean} [allowFullHistoryFetch=false] - Explicit opt-in to unbounded prerequisite recovery. * @returns {Promise} */ -async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi, baseBranch) { - let bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`; +async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi, allowFullHistoryFetch = false) { + const bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`; const bundleTargetRef = `refs/heads/${branchName}`; const bundleTempRef = createBundleTempRef(branchName); try { - await ensureFullHistoryForBundle(execApi, {}, { baseRef: baseBranch, bundleFilePath }); + await ensureFullHistoryForBundle(execApi, {}, { bundleFilePath, allowFullHistoryFetch }); core.info(`Applying bundle ${bundleFilePath} to ${bundleTargetRef} using temp ref ${bundleTempRef} from ${bundleBranchRef}`); - // Fetch from bundle into a temporary ref, then update the target branch. - // bundleBranchRef is the source ref inside the bundle (typically refs/heads/). - // Use getExecOutput with ignoreReturnCode so we can read the actual stderr from git — - // exec() only throws "The process '...' failed with exit code 1" which loses the - // "lacks these prerequisite commits" text needed for the recovery path below. - core.info(`Attempting bundle fetch from ${bundleBranchRef} into ${bundleTempRef}`); - const initialBundleFetch = await execApi.getExecOutput("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`], { ignoreReturnCode: true }); - if (initialBundleFetch.exitCode !== 0) { - const initialFetchErrorOutput = initialBundleFetch.stderr || `exit code ${initialBundleFetch.exitCode}`; - - // Recovery path for bundle prerequisite failures: fetch missing prerequisite - // commit objects, then retry with the original bundle ref. - // This handles the race where main advanced between agent-time and safe_outputs-time: - // the bundle's base commit may not be reachable from a fetch-depth:1 shallow clone - // (e.g. when the commit is on a ref not in the fetch refspec). - const prerequisiteCommits = extractBundlePrerequisiteCommits(initialFetchErrorOutput); - if (prerequisiteCommits.length > 0) { - core.warning(`Bundle fetch with ${bundleBranchRef} failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); - core.info(`Prerequisite commits: ${summarizeListForLog(prerequisiteCommits)}`); - core.info(`Fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`); - // Use --filter=blob:none only when the local repo is already shallow or sparse — - // in a full clone we already have all blobs and must not convert the repo to a - // partial clone (which would trigger lazy blob fetches on later operations). - const useBlobFilter = await isShallowOrSparseCheckout(execApi); - const prerequisiteFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...prerequisiteCommits] : ["fetch", "origin", ...prerequisiteCommits]; - if (useBlobFilter) { - core.info("Using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)"); - } - await execApi.exec("git", prerequisiteFetchArgs); - core.info("Fetched prerequisite commits from origin successfully"); - try { - core.info(`Retrying bundle fetch from ${bundleBranchRef} into ${bundleTempRef} after prerequisite recovery`); - await execApi.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`]); - core.info("Bundle fetch retry succeeded after prerequisite recovery"); - } catch (retryError) { - throw new Error(`Bundle fetch failed after fetching ${prerequisiteCommits.length} prerequisite commit(s): ${getErrorMessage(retryError)}`, { cause: retryError }); - } - } else { - // Fallback: resolve the source ref directly from the bundle contents. - // Some agents may emit a JSONL branch name that differs from the ref embedded in the bundle. - core.warning(`Bundle fetch with ${bundleBranchRef} failed: ${initialFetchErrorOutput}; resolving branch ref from bundle heads`); - core.info(`Inspecting bundle heads from ${bundleFilePath}`); - const { stdout: bundleHeadsOutput } = await execApi.getExecOutput("git", ["bundle", "list-heads", bundleFilePath]); - const branchRefs = bundleHeadsOutput - .split("\n") - .map(line => line.trim().split(/\s+/)[1] || "") - .filter(ref => /^refs\/heads\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(ref)); - core.info(`Bundle list-heads returned ${branchRefs.length} candidate branch ref(s): ${summarizeListForLog(branchRefs)}`); - - if (branchRefs.length === 1) { - bundleBranchRef = branchRefs[0]; - core.info(`Resolved bundle source ref from list-heads: ${bundleBranchRef}`); - core.info(`Fetching resolved bundle ref ${bundleBranchRef} into ${bundleTempRef}`); - await execApi.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`]); - } else if (branchRefs.length === 0) { - // Bundle contains only HEAD (no refs/heads/* entry). This happens when the - // agent created the bundle with `git bundle create HEAD` rather than - // including a named branch ref. Fetch using the HEAD refspec directly so - // the bundle objects become accessible, then point the temp ref at them. - const headLine = bundleHeadsOutput - .split("\n") - .map(line => line.trim()) - .find(line => /^[0-9a-f]{40}\s+HEAD$/.test(line)); - if (headLine) { - core.info(`Bundle has no refs/heads entries; fetching HEAD directly into ${bundleTempRef}`); - // Use getExecOutput with ignoreReturnCode so we can read actual stderr - // and perform prerequisite recovery before failing — same pattern as the - // initial bundle fetch above. When the agent ran on a non-default branch - // the bundle prerequisite is that branch tip, which isn't reachable from - // the local main-only checkout, so a bare exec() would throw and lose the - // "lacks these prerequisite commits" text needed for recovery. - const headBundleFetch = await execApi.getExecOutput("git", ["fetch", bundleFilePath, `HEAD:${bundleTempRef}`], { ignoreReturnCode: true }); - if (headBundleFetch.exitCode !== 0) { - const headFetchErrorOutput = headBundleFetch.stderr || `exit code ${headBundleFetch.exitCode}`; - const headPrereqCommits = extractBundlePrerequisiteCommits(headFetchErrorOutput); - if (headPrereqCommits.length > 0) { - core.warning(`HEAD bundle fetch failed due to ${headPrereqCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); - core.info(`Prerequisite commits: ${summarizeListForLog(headPrereqCommits)}`); - const useBlobFilter = await isShallowOrSparseCheckout(execApi); - const headPrereqFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...headPrereqCommits] : ["fetch", "origin", ...headPrereqCommits]; - if (useBlobFilter) { - core.info("Using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)"); - } - await execApi.exec("git", headPrereqFetchArgs); - core.info("Fetched HEAD bundle prerequisite commits from origin successfully"); - try { - core.info(`Retrying HEAD bundle fetch into ${bundleTempRef} after prerequisite recovery`); - await execApi.exec("git", ["fetch", bundleFilePath, `HEAD:${bundleTempRef}`]); - core.info("HEAD bundle fetch retry succeeded after prerequisite recovery"); - } catch (retryError) { - const retryErrorMessage = getErrorMessage(retryError); - throw new Error(`HEAD bundle fetch failed after fetching ${headPrereqCommits.length} prerequisite commit(s): ${retryErrorMessage}`, { - cause: retryError, - }); - } - } else { - throw new Error(`Failed to apply HEAD-only bundle: ${headFetchErrorOutput}`); - } - } - } else { - throw new Error(`Failed to resolve bundle branch ref from list-heads: bundle contains no refs/heads entries and no HEAD ref`); - } - } else { - throw new Error(`Failed to resolve bundle branch ref from list-heads: expected exactly 1 refs/heads entry, found ${branchRefs.length}`); - } - } - } - core.info(`Fetched bundle to ${bundleTempRef}`); + await importLocalBundle(execApi, bundleFilePath, bundleBranchRef, bundleTempRef); + core.info(`Imported local bundle to ${bundleTempRef}`); await execApi.exec("git", ["update-ref", bundleTargetRef, bundleTempRef]); core.info(`Created local branch ${branchName} from bundle`); - await execApi.exec("git", ["checkout", branchName]); + await execApi.exec("git", ["checkout", branchName], offlineGitOptions()); // Ensure the working tree matches the new HEAD in case checkout left any index/working tree drift. - await execApi.exec("git", ["reset", "--hard"]); + await execApi.exec("git", ["reset", "--hard"], offlineGitOptions()); core.info(`Checked out branch ${branchName} from bundle`); } finally { try { @@ -396,7 +290,7 @@ async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi, bundleFile // fall back cleanly rather than letting linearizeRangeAsCommit abort mid-run. const prereqSha = prereqs[0]; try { - const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${prereqSha}^{commit}`], { ignoreReturnCode: true, silent: true }); + const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${prereqSha}^{commit}`], { ...offlineGitOptions(), ignoreReturnCode: true }); if (exitCode === 0) { baseRef = prereqSha; core.info(`Using bundle prerequisite commit ${baseRef} as linearization base (avoids including base-branch drift)`); @@ -1829,11 +1723,11 @@ async function main(config = {}) { // unlike git format-patch which flattens history and drops merge resolution content. core.info(`Applying changes from bundle: ${bundleFilePath}`); try { - await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec, baseBranch); + await applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, exec, config.allow_full_history_fetch === true); } catch (bundleError) { transport.assertUnchanged(); core.error(`Failed to apply bundle: ${getErrorMessage(bundleError)}`); - return { success: false, error: "Failed to apply bundle" }; + return { success: false, error: `Failed to apply bundle: ${getErrorMessage(bundleError)}` }; } // Push the commits from the bundle to the remote branch diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index fdbacc05837..5a47c11e0bb 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -511,8 +511,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => { } if (cmd === "git" && args[0] === "cat-file" && args[1] === "-e") { // Report the prerequisite object as already present by default so - // ensureFullHistoryForBundle returns early (no fetch). Tests that need - // to exercise the fetch/deepen path override this within the test. + // ensureFullHistoryForBundle returns early without fetching. return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); } if (cmd === "git" && args[0] === "rev-list") { @@ -521,7 +520,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => { if (cmd === "git" && args && args[0] === "ls-remote") { return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }), }; @@ -553,7 +552,7 @@ describe("create_pull_request - bundle transport shallow checkout", () => { vi.clearAllMocks(); }); - it("should fetch bundle prerequisite commits directly from origin in shallow repositories", async () => { + it("fails actionably instead of downloading missing prerequisites in a shallow repository", async () => { const patchPath = canonicalPatchPath("feature/test"); fs.writeFileSync( patchPath, @@ -596,7 +595,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); global.exec.exec = vi.fn().mockImplementation((cmd, args) => { if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereq)) { @@ -609,21 +608,11 @@ index 0000000..abc1234 const handler = await main({ base_branch: "main", preserve_branch_name: true }); const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test" }, {}); - expect(result.success).toBe(true); - // Initial bundle fetch is now via getExecOutput (with ignoreReturnCode: true) rather than exec, - // so the bundle fetch appears in getExecOutput.mock.calls. - const bundleFetchCall = global.exec.getExecOutput.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); - if (!bundleFetchCall) { - throw new Error("expected bundle fetch call via getExecOutput"); - } - expect(bundleFetchCall[1][2]).toMatch(/^refs\/heads\/feature\/test:refs\/bundles\/create-pr-feature-test-[a-f0-9]{8}$/); - const bundleTempRef = bundleFetchCall[1][2].split(":")[1]; - expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/feature/test", bundleTempRef]); - expect(global.exec.exec).toHaveBeenCalledWith("git", ["reset", "--hard"]); - // Primary path: the exact prerequisite SHA is fetched directly from origin, - // with no broad iterative deepen and no --unshallow. + expect(result.success).toBe(false); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(result.error).toContain("safe-outputs.create-pull-request"); const directFetch = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereq)); - expect(directFetch).toBeTruthy(); + expect(directFetch).toBeUndefined(); const deepenCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && typeof args[1] === "string" && args[1].startsWith("--deepen=")); expect(deepenCall).toBeUndefined(); const unshallowCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args.includes("--unshallow")); @@ -779,7 +768,7 @@ index 0000000..abc1234 return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } if (cmd === "git" && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { revParseHeadCallCount += 1; @@ -792,7 +781,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "diff" && args[1] === "--cached" && args[2] === "--name-only") { return Promise.resolve({ exitCode: 0, stdout: "test.txt\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); pushSignedSpy @@ -811,7 +800,7 @@ index 0000000..abc1234 expect(global.github.rest.issues.create).not.toHaveBeenCalled(); }); - it("should resolve bundle source ref from list-heads when JSONL branch ref is missing in bundle", async () => { + it("resolves the source from local unbundle output when the JSONL branch differs", async () => { const patchPath = canonicalPatchPath("ops-review-may09-2026"); fs.writeFileSync( patchPath, @@ -845,7 +834,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { return Promise.resolve({ exitCode: 1, stderr: "fatal: couldn't find remote ref refs/heads/ops-review-may09-2026", stdout: "" }); } - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads" && isBundleSnapshot(args[2], bundlePath)) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath)) { return Promise.resolve({ exitCode: 0, stdout: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n", @@ -853,9 +842,9 @@ index 0000000..abc1234 }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const { main } = require("./create_pull_request.cjs"); @@ -863,12 +852,8 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: "ops-review-may09-2026" }, {}); expect(result.success).toBe(true); - expect(global.exec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "list-heads", bundleSnapshot(bundlePath)]); - const resolvedFetchCall = global.exec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && args[2].startsWith("refs/heads/main:")); - if (!resolvedFetchCall) { - throw new Error("expected resolved bundle fetch call"); - } - expect(resolvedFetchCall[1][2]).toMatch(/^refs\/heads\/main:refs\/bundles\/create-pr-ops-review-may09-2026-[a-f0-9]{8}$/); + expect(global.exec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "unbundle", bundleSnapshot(bundlePath)], expect.objectContaining({ env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "" }) })); + expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", expect.stringMatching(/^refs\/bundles\/create-pr-ops-review-may09-2026-[a-f0-9]{8}$/), "a".repeat(40)], expect.any(Object)); }); it("should fall back to HEAD refspec when bundle contains only HEAD (no refs/heads/* entry)", async () => { @@ -907,10 +892,10 @@ index 0000000..abc1234 } // HEAD-based bundle fetch (fallback path) succeeds — no prerequisite errors if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode && typeof args[2] === "string" && args[2].startsWith("HEAD:")) { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } // Bundle contains only HEAD — no refs/heads/* entry (the bug scenario) - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads" && isBundleSnapshot(args[2], bundlePath)) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath)) { return Promise.resolve({ exitCode: 0, stdout: "ac85f4047717ec43c931d750575f5251c45dc705 HEAD\n", @@ -918,9 +903,9 @@ index 0000000..abc1234 }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const { main } = require("./create_pull_request.cjs"); @@ -928,19 +913,15 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: "docs/update-migration-version-2026-05-19-4fe3b9f7f99fc1d6" }, {}); expect(result.success).toBe(true); - expect(global.exec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "list-heads", bundleSnapshot(bundlePath)]); - // HEAD-based bundle fetch is now performed via getExecOutput (ignoreReturnCode: true) - // so the code can distinguish prerequisite errors from other failures. - const headFetchCall = global.exec.getExecOutput.mock.calls.find( - ([, args, opts]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && typeof args[2] === "string" && args[2].startsWith("HEAD:") && opts && opts.ignoreReturnCode + expect(global.exec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "unbundle", bundleSnapshot(bundlePath)], expect.objectContaining({ env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "" }) })); + expect(global.exec.exec).toHaveBeenCalledWith( + "git", + ["update-ref", expect.stringMatching(/^refs\/bundles\/create-pr-docs-update-migration-version-2026-05-19-4fe3b9f7f99fc1d6-[a-f0-9]{8}$/), "ac85f4047717ec43c931d750575f5251c45dc705"], + expect.any(Object) ); - if (!headFetchCall) { - throw new Error("expected HEAD-based bundle fetch call via getExecOutput"); - } - expect(headFetchCall[1][2]).toMatch(/^HEAD:refs\/bundles\/create-pr-docs-update-migration-version-2026-05-19-4fe3b9f7f99fc1d6-[a-f0-9]{8}$/); }); - it("should fetch prerequisite commits from origin and retry when HEAD-only bundle has missing prerequisites (non-main dispatch scenario)", async () => { + it("fails without downloading prerequisites when a HEAD-only bundle was generated from a different branch", async () => { // Simulates: worker was dispatched from a non-main branch; its bundle has only HEAD // (no refs/heads/* entry) AND the prerequisite is the feature-branch tip, which is // not reachable from the local main-only shallow checkout in safe_outputs. @@ -987,32 +968,31 @@ index 0000000..abc1234 return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${featureBranchTip}`, stdout: "" }); } // Bundle contains only HEAD — no refs/heads/* entry - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads" && isBundleSnapshot(args[2], bundlePath)) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath)) { return Promise.resolve({ - exitCode: 0, + exitCode: 1, stdout: `ac85f4047717ec43c931d750575f5251c45dc705 HEAD\n`, - stderr: "", + stderr: `error: Repository lacks these prerequisite commits:\nerror: ${featureBranchTip}`, }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const { main } = require("./create_pull_request.cjs"); const handler = await main({ base_branch: "main", preserve_branch_name: true }); const result = await handler({ title: "Test PR", body: "Test body", branch: branchName }, {}); - expect(result.success).toBe(true); - // Feature-branch tip prerequisite is fetched from origin - expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", featureBranchTip]); - // After prerequisite recovery, HEAD bundle is retried via exec + expect(result.success).toBe(false); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(global.exec.exec).not.toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", featureBranchTip]); const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && typeof args[2] === "string" && args[2].startsWith("HEAD:")); - expect(bundleRetryFetchCalls.length).toBe(1); + expect(bundleRetryFetchCalls.length).toBe(0); }); - it("should include retry context when HEAD-only bundle fetch still fails after prerequisite recovery", async () => { + it("does not retry a failed HEAD-only import with a network fetch", async () => { const branchName = "docs/update-migration-version-2026-05-19-4fe3b9f7f99fc1d6"; const patchPath = canonicalPatchPath(branchName); fs.writeFileSync( @@ -1051,17 +1031,17 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode && typeof args[2] === "string" && args[2].startsWith("HEAD:")) { return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${featureBranchTip}`, stdout: "" }); } - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads" && isBundleSnapshot(args[2], bundlePath)) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath)) { return Promise.resolve({ - exitCode: 0, + exitCode: 1, stdout: `ac85f4047717ec43c931d750575f5251c45dc705 HEAD\n`, - stderr: "", + stderr: `error: Repository lacks these prerequisite commits:\nerror: ${featureBranchTip}`, }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); global.exec.exec.mockImplementation((cmd, args) => { @@ -1076,11 +1056,11 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: branchName }, {}); expect(result.success).toBe(false); - expect(result.error).toBe("Failed to apply bundle"); - expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("HEAD bundle fetch failed after fetching 1 prerequisite commit(s): fatal: failed to read HEAD-only bundle")); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(global.exec.exec.mock.calls.some(([, args]) => args[0] === "fetch" && (args.includes(featureBranchTip) || args.includes("--unshallow")))).toBe(false); }); - it("should fetch prerequisite commits and retry bundle fetch when prerequisites are missing", async () => { + it("fails actionably when a local bundle import reports missing prerequisites", async () => { const patchPath = canonicalPatchPath("feature/test"); fs.writeFileSync( patchPath, @@ -1114,29 +1094,29 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath) && options && options.ignoreReturnCode) { return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${missingSha}`, stdout: "" }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const { main } = require("./create_pull_request.cjs"); const handler = await main({ base_branch: "main", preserve_branch_name: true }); const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test" }, {}); - expect(result.success).toBe(true); - // Prerequisites are fetched from origin via exec with --filter=blob:none to avoid downloading blobs - expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", missingSha]); + expect(result.success).toBe(false); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(global.exec.exec).not.toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", missingSha]); // Retry bundle fetch is via exec (only the retry, not the initial attempt which was getExecOutput) const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); - expect(bundleRetryFetchCalls.length).toBe(1); - expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["bundle", "list-heads", bundleSnapshot(bundlePath)]); + expect(bundleRetryFetchCalls.length).toBe(0); + expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["bundle", "list-heads", bundlePath]); }); - it("should fetch all prerequisite commits in a single origin fetch and retry bundle fetch", async () => { + it("does not fetch any prerequisites when several are missing", async () => { const patchPath = canonicalPatchPath("feature/test"); fs.writeFileSync( patchPath, @@ -1168,27 +1148,28 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath) && options && options.ignoreReturnCode) { return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${missingSha1}\nerror: ${missingSha2}`, stdout: "" }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const { main } = require("./create_pull_request.cjs"); const handler = await main({ base_branch: "main", preserve_branch_name: true }); const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test" }, {}); - expect(result.success).toBe(true); - expect(global.exec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", missingSha1, missingSha2]); + expect(result.success).toBe(false); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(global.exec.exec).not.toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", missingSha1, missingSha2]); const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); - expect(bundleRetryFetchCalls.length).toBe(1); - expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["bundle", "list-heads", bundleSnapshot(bundlePath)]); + expect(bundleRetryFetchCalls.length).toBe(0); + expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["bundle", "list-heads", bundlePath]); }); - it("should fail when fetching prerequisite commits from origin fails", async () => { + it("does not expose remote credential diagnostics when prerequisites are missing", async () => { const patchPath = canonicalPatchPath("feature/test"); fs.writeFileSync( patchPath, @@ -1219,13 +1200,13 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath) && options && options.ignoreReturnCode) { return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${missingSha}`, stdout: "" }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); global.exec.exec.mockImplementation((cmd, args) => { if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(missingSha)) { @@ -1239,14 +1220,14 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test" }, {}); expect(result.success).toBe(false); - expect(result.error).toBe("Failed to apply bundle"); - expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Failed to apply bundle: fatal: couldn't connect to 'origin'")); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(JSON.stringify(global.core.error.mock.calls)).not.toContain("couldn't connect"); // No retry bundle fetch via exec — failed at prerequisite origin fetch const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); expect(bundleRetryFetchCalls.length).toBe(0); }); - it("should include retry context when bundle fetch still fails after prerequisite recovery", async () => { + it("does not retry a failed local bundle import", async () => { const patchPath = canonicalPatchPath("feature/test"); fs.writeFileSync( patchPath, @@ -1277,13 +1258,13 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath) && options && options.ignoreReturnCode) { return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${missingSha}`, stdout: "" }); } if (cmd === "git" && args && args[0] === "ls-remote") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); global.exec.exec.mockImplementation((cmd, args) => { if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)) { @@ -1297,10 +1278,9 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test" }, {}); expect(result.success).toBe(false); - expect(result.error).toBe("Failed to apply bundle"); - expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Bundle fetch failed after fetching 1 prerequisite commit(s): fatal: failed to read bundle")); + expect(result.error).toContain("allow-full-history-fetch: true"); const bundleRetryFetchCalls = global.exec.exec.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); - expect(bundleRetryFetchCalls.length).toBe(1); + expect(bundleRetryFetchCalls.length).toBe(0); }); it("should not fetch a bundle directly into the target branch", async () => { @@ -1331,14 +1311,12 @@ index 0000000..abc1234 const result = await handler({ title: "Test PR", body: "Test body\n\nCloses #57\nResolves test-owner/test-repo#58", branch: "autoloop/perf-comparison" }, {}); expect(result.success).toBe(true); - // The initial bundle fetch uses getExecOutput (not exec.exec) — ensure it never uses the direct branch refspec - expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["fetch", bundleSnapshot(bundlePath), "refs/heads/autoloop/perf-comparison:refs/heads/autoloop/perf-comparison"], expect.anything()); - const bundleFetchCall = global.exec.getExecOutput.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); - if (!bundleFetchCall) { - throw new Error("expected bundle fetch call"); - } - expect(bundleFetchCall[1][2]).toMatch(/^refs\/heads\/autoloop\/perf-comparison:refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8}$/); - const bundleTempRef = bundleFetchCall[1][2].split(":")[1]; + // Import first updates a temporary ref, never the checked-out branch. + expect(global.exec.getExecOutput).not.toHaveBeenCalledWith("git", ["fetch", bundlePath, "refs/heads/autoloop/perf-comparison:refs/heads/autoloop/perf-comparison"], expect.anything()); + const bundleImportCall = global.exec.exec.mock.calls.find(([, args]) => args[0] === "update-ref" && args[1].startsWith("refs/bundles/create-pr-")); + expect(bundleImportCall).toBeDefined(); + const bundleTempRef = bundleImportCall[1][1]; + expect(bundleTempRef).toMatch(/^refs\/bundles\/create-pr-autoloop-perf-comparison-[a-f0-9]{8}$/); expect(global.exec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/autoloop/perf-comparison", bundleTempRef]); }); @@ -2138,7 +2116,7 @@ describe("create_pull_request - allowed-files strict allowlist", () => { }; global.exec = { exec: vi.fn().mockResolvedValue(0), - getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }), + getExecOutput: vi.fn().mockImplementation((_cmd, args) => Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "abc123\n", stderr: "" })), }; const pushSignedCommitsModule = require("./push_signed_commits.cjs"); pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("bundle-tip"); diff --git a/actions/setup/js/create_pull_request_bundle_integration.test.cjs b/actions/setup/js/create_pull_request_bundle_integration.test.cjs index c299f6cddb0..42e8af8d0ca 100644 --- a/actions/setup/js/create_pull_request_bundle_integration.test.cjs +++ b/actions/setup/js/create_pull_request_bundle_integration.test.cjs @@ -61,11 +61,11 @@ function createRepo(prefix) { function createExecApi(cwd, onExec) { return { - async exec(command, args = []) { + async exec(command, args = [], options = {}) { if (command !== "git") { throw new Error(`unexpected command: ${command}`); } - const result = execGit(args, { cwd, allowFailure: true }); + const result = execGit(args, { ...options, cwd, allowFailure: true }); if (result.status !== 0) { throw new Error(result.stderr || result.stdout); } @@ -78,7 +78,7 @@ function createExecApi(cwd, onExec) { if (command !== "git") { throw new Error(`unexpected command: ${command}`); } - const result = execGit(args, { cwd, allowFailure: true }); + const result = execGit(args, { ...options, cwd, allowFailure: true }); if (result.status !== 0 && !options.ignoreReturnCode) { throw new Error(result.stderr || result.stdout); } @@ -179,9 +179,11 @@ describe("create_pull_request bundle integration", () => { branchName, "", createExecApi(targetRepo, args => { - if (args[0] === "fetch" && args[1] === bundlePath) { - bundleTempRef = args[2].split(":")[1]; + expect(args[0]).not.toBe("fetch"); + if (args[0] === "update-ref" && args[1].startsWith("refs/bundles/")) { + bundleTempRef = args[1]; expect(execGit(["show-ref", "--verify", bundleTempRef], { cwd: targetRepo }).status).toBe(0); + expect(execGit(["rev-parse", bundleTempRef], { cwd: targetRepo }).stdout.trim()).toBe(expectedHead); } }) ); @@ -217,8 +219,10 @@ describe("create_pull_request bundle integration", () => { let bundleTempRef = ""; const execApi = createExecApi(targetRepo, args => { - if (args[0] === "fetch" && args[1] === bundlePath) { - bundleTempRef = args[2].split(":")[1]; + expect(args[0]).not.toBe("fetch"); + if (args[0] === "update-ref" && args[1].startsWith("refs/bundles/")) { + bundleTempRef = args[1]; + expect(execGit(["show-ref", "--verify", bundleTempRef], { cwd: targetRepo }).status).toBe(0); } }); const { applyBundleToBranch } = require("./create_pull_request.cjs"); @@ -226,11 +230,11 @@ describe("create_pull_request bundle integration", () => { await expect( applyBundleToBranch(bundlePath, branchName, "", { ...execApi, - async exec(command, args = []) { + async exec(command, args = [], options = {}) { if (command === "git" && args[0] === "update-ref" && args[1] === `refs/heads/${branchName}`) { throw new Error("simulated update-ref failure"); } - return execApi.exec(command, args); + return execApi.exec(command, args, options); }, }) ).rejects.toThrow("simulated update-ref failure"); diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index a0c8c2ac399..eef4fd0dec7 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -436,23 +436,65 @@ function hasMergeCommitsInRange(baseRef, headRef, options = {}) { } } +const BUNDLE_HISTORY_ERROR = + "Bundle prerequisites are unavailable locally. Automatic history fetching is disabled by default. " + + "Provision the required objects during authenticated checkout, or set allow-full-history-fetch: true under safe-outputs.create-pull-request or safe-outputs.push-to-pull-request-branch in workflow frontmatter to enable recovery for that handler. " + + "Opting in can download the entire repository history; commit depths and blob filters are not byte limits."; + /** - * Fallback deepen step size (commits added per `git fetch --deepen=N` call). + * Deny every Git transport, including file and external helpers, during local + * inspection. GIT_ALLOW_PROTOCOL predates the supported Git minimum; unlike + * GIT_NO_LAZY_FETCH alone, it also protects older Git versions from promisor + * downloads. Neither caller options nor repository config may relax this guard. * - * The primary path fetches the exact prerequisite commit SHAs directly from - * origin (see `ensureFullHistoryForBundle`), so this iterative deepen only runs - * when fetch-by-SHA is unavailable or insufficient. We deepen in small - * increments so a single fetch never tries to pull a huge slice of history, - * which can time out on large monorepos with long, complex branch histories. + * @param {Object} [options] + * @returns {Object} */ -const BUNDLE_DEEPEN_STEP = 5; +function offlineGitOptions(options = {}) { + return { + ...options, + env: { ...process.env, ...options.env, GIT_ALLOW_PROTOCOL: "", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" }, + silent: true, + }; +} /** - * Maximum number of fallback deepen iterations before giving up and attempting - * `--unshallow`. With a step of 5 this caps the fallback at ~1000 commits of - * deepening (200 * 5) before the last-resort unshallow. + * Import a local bundle without opening any transport (including promisor + * remotes). Unlike fetch, bundle unbundle works with every protocol denied. + * + * @param {{ getExecOutput: Function, exec: Function }} execApi + * @param {string} bundleFilePath + * @param {string} sourceRef + * @param {string} targetRef + * @param {Object} [options] + * @returns {Promise} */ -const BUNDLE_DEEPEN_MAX_ITERATIONS = 200; +async function importLocalBundle(execApi, bundleFilePath, sourceRef, targetRef, options = {}) { + const opts = offlineGitOptions(options); + const result = await execApi.getExecOutput("git", ["bundle", "unbundle", bundleFilePath], { ...opts, ignoreReturnCode: true }); + if (result.exitCode !== 0) { + if (extractBundlePrerequisiteCommits(result.stderr).length > 0) throw new Error(BUNDLE_HISTORY_ERROR); + throw new Error("Unable to import local bundle with network access disabled. Verify the bundle and provision its required objects during authenticated checkout."); + } + const heads = []; + for (const line of result.stdout.trim().split("\n")) { + const match = /^([0-9a-f]{40}) (HEAD|refs\/heads\/\S+)$/.exec(line); + if (!match) continue; + const [, sha, ref] = match; + if (ref !== "HEAD") { + const valid = await execApi.getExecOutput("git", ["check-ref-format", ref], { ...opts, ignoreReturnCode: true }); + if (valid.exitCode !== 0) continue; + } + heads.push({ sha, ref }); + } + let selected = heads.filter(head => head.ref === sourceRef); + if (selected.length === 0) { + selected = heads.filter(head => head.ref.startsWith("refs/heads/")); + if (selected.length === 0) selected = heads.filter(head => head.ref === "HEAD"); + } + if (selected.length !== 1) throw new Error("Unable to resolve a unique source ref from the local bundle."); + await execApi.exec("git", ["update-ref", targetRef, selected[0].sha], opts); +} /** * Extract prerequisite commit SHAs declared in a git bundle file. @@ -469,7 +511,7 @@ const BUNDLE_DEEPEN_MAX_ITERATIONS = 200; */ async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) { try { - const { stdout, stderr } = await execApi.getExecOutput("git", ["bundle", "verify", bundleFilePath], { ...options, ignoreReturnCode: true, silent: true }); + const { stdout, stderr } = await execApi.getExecOutput("git", ["bundle", "verify", bundleFilePath], { ...offlineGitOptions(options), ignoreReturnCode: true }); const combined = `${stdout || ""}\n${stderr || ""}`; const prereqs = new Set(); const lines = combined.split(/\r?\n/); @@ -500,7 +542,7 @@ async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) { } return [...prereqs]; } catch (error) { - core.debug(`getBundlePrerequisites failed: ${getErrorMessage(error)}`); + core.debug("Unable to inspect local bundle prerequisites"); return []; } } @@ -524,7 +566,7 @@ async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) { async function findMissingObjects(execApi, shas, options = {}) { const missing = []; for (const sha of shas) { - const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${sha}^{commit}`], { ...options, ignoreReturnCode: true, silent: true }); + const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${sha}^{commit}`], { ...offlineGitOptions(options), ignoreReturnCode: true }); if (exitCode !== 0) { missing.push(sha); } @@ -533,66 +575,25 @@ async function findMissingObjects(execApi, shas, options = {}) { } /** - * Ensure a shallow checkout contains the prerequisite commits a git bundle - * needs before `git fetch ` is attempted. - * - * Bundles generated from a commit range declare prerequisite commits. A shallow - * checkout (e.g. `fetch-depth: 20`) may not contain them, and `git fetch - * ` rejects the bundle before the caller can update refs. - * - * Strategy (best → worst): - * 1. **Direct SHA fetch (primary).** The bundle declares *exactly* which - * commits it requires (`git bundle verify`). We fetch those SHAs directly - * from origin (`git fetch origin ...`). GitHub honors fetch-by-SHA, so - * this brings precisely the needed objects and is deterministic — it works - * even when a prerequisite lives on the PR branch and is not an ancestor of - * the base branch. This avoids walking back the base history entirely. - * 2. **Iterative deepen (fallback).** Only when fetch-by-SHA is unavailable or - * insufficient, deepen `origin/` in small `BUNDLE_DEEPEN_STEP` - * increments (re-checking object presence each step) up to - * `BUNDLE_DEEPEN_MAX_ITERATIONS`. Small steps keep any single fetch cheap so - * it cannot time out by pulling a huge slice of a large monorepo's history. - * 3. **`--unshallow` (last resort).** On a high-churn monorepo this downloads - * the entire history, so it is only attempted after the bounded deepen. - * - * When `deepenOptions.baseRef` or `deepenOptions.bundleFilePath` is missing - * (legacy callers), the function falls back to a single - * `git fetch --unshallow origin`. + * Check bundle prerequisites locally. No recovery downloads are allowed unless + * trusted workflow configuration explicitly opts into unbounded history fetches. * * @param {{ getExecOutput: Function, exec: Function }} execApi - Exec API to run git commands. * @param {Object} [options] - Options passed through to exec calls. * @param {Object} [deepenOptions] - * @param {string} [deepenOptions.baseRef] - Remote branch name to deepen (no `origin/` prefix). * @param {string} [deepenOptions.bundleFilePath] - Path to the bundle file whose prerequisites must become reachable. + * @param {boolean} [deepenOptions.allowFullHistoryFetch=false] - Explicit opt-in to potentially unbounded recovery downloads. + * @param {string} [deepenOptions.remote="origin"] - Trusted handler-selected repository supplying prerequisites. + * @param {(fetch: () => Promise) => Promise} [deepenOptions.withRecoveryCredentials] - Scope head-repository credentials to recovery only. * @returns {Promise} */ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = {}) { - let stdout; - try { - ({ stdout } = await execApi.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], options)); - } catch (error) { - const message = getErrorMessage(error); - core.warning(`Could not determine shallow repository status; skipping full-history fetch probe: ${message}`); - return; - } - if (stdout.trim() !== "true") { - return; - } - - const { baseRef, bundleFilePath } = deepenOptions || {}; - - // Legacy path: no base ref / bundle info known — fall back to a single - // unshallow. Callers in monorepos should always supply baseRef + bundleFilePath - // to get targeted prerequisite fetching instead. - if (!baseRef || !bundleFilePath) { - core.info("Repository is shallow; fetching full history before bundle processing (no baseRef/bundle info; using --unshallow)"); - await execApi.exec("git", ["fetch", "--unshallow", "origin"], options); - return; - } + const { bundleFilePath, allowFullHistoryFetch = false, remote = "origin", withRecoveryCredentials = fetch => fetch() } = deepenOptions || {}; + if (!bundleFilePath) throw new Error("A local bundle path is required to check prerequisites; no history was fetched."); const prereqs = await getBundlePrerequisites(execApi, bundleFilePath, options); if (prereqs.length === 0) { - core.info("Bundle declares no prerequisites; no deepen required"); + core.info("Bundle declares no prerequisites; no history fetch required"); return; } @@ -602,51 +603,28 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = return; } - // PRIMARY: fetch the exact prerequisite commit SHAs directly from origin. - // The bundle tells us precisely which commits it needs, so a targeted fetch by - // SHA brings exactly those objects without deepening the base branch history. - core.info(`Repository is shallow; fetching ${missing.length} bundle prerequisite commit(s) directly from origin by SHA`); - const useBlobFilter = await isShallowOrSparseCheckout(execApi, options); - const directFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...missing] : ["fetch", "origin", ...missing]; - if (useBlobFilter) { - core.info("Using --filter=blob:none for prerequisite SHA fetch (shallow or sparse checkout detected)"); - } + if (allowFullHistoryFetch !== true) throw new Error(BUNDLE_HISTORY_ERROR); + core.warning("Bundle history recovery explicitly enabled; this may download the entire repository history without a byte limit"); + const fetchOptions = { ...options, silent: true, ignoreReturnCode: true }; try { - await execApi.exec("git", directFetchArgs, options); - missing = await findMissingObjects(execApi, prereqs, options); - if (missing.length === 0) { - core.info("Bundle prerequisite commits fetched directly from origin; no deepen required"); - return; - } - core.warning(`${missing.length} prerequisite commit(s) still missing after direct SHA fetch; falling back to iterative deepen`); - } catch (directFetchError) { - core.warning(`Direct prerequisite SHA fetch failed: ${getErrorMessage(directFetchError)}; falling back to iterative deepen`); - } - - // FALLBACK: deepen origin/ in small increments, re-checking object - // presence after each step, until the prerequisites are present or the - // iteration cap is reached. - core.info(`Iteratively deepening origin/${baseRef} by ${BUNDLE_DEEPEN_STEP} commit(s) at a time to satisfy ${missing.length} prerequisite commit(s)`); - for (let iteration = 1; iteration <= BUNDLE_DEEPEN_MAX_ITERATIONS; iteration++) { - try { - await execApi.exec("git", ["fetch", `--deepen=${BUNDLE_DEEPEN_STEP}`, "origin", baseRef], options); - } catch (fetchError) { - core.warning(`git fetch --deepen=${BUNDLE_DEEPEN_STEP} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`); - break; - } + await withRecoveryCredentials(async () => { + const shallow = await execApi.getExecOutput("git", ["rev-parse", "--is-shallow-repository"], { ...offlineGitOptions(options), ignoreReturnCode: true }); + if (shallow.exitCode !== 0) throw new Error("Shallow status unavailable"); + if (shallow.stdout.trim() === "true") { + const unshallow = await execApi.getExecOutput("git", ["fetch", "--unshallow", remote], fetchOptions); + if (unshallow.exitCode !== 0) throw new Error("History fetch failed"); + } + missing = await findMissingObjects(execApi, prereqs, options); + if (missing.length > 0) { + const fetch = await execApi.getExecOutput("git", ["fetch", remote, ...missing], fetchOptions); + if (fetch.exitCode !== 0) throw new Error("Prerequisite fetch failed"); + } + }); missing = await findMissingObjects(execApi, prereqs, options); - if (missing.length === 0) { - core.info(`Bundle prerequisite commits present after deepening ${iteration * BUNDLE_DEEPEN_STEP} commit(s)`); - return; - } - } - - core.warning(`Bundle prerequisites still not present after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`); - try { - await execApi.exec("git", ["fetch", "--unshallow", "origin", baseRef], options); - } catch (unshallowError) { - core.warning(`Fallback --unshallow fetch failed: ${getErrorMessage(unshallowError)}; bundle apply may still fail`); + } catch { + throw new Error("Explicit bundle history recovery failed. Provision the required objects during authenticated checkout with credentials for the recovery repository."); } + if (missing.length > 0) throw new Error("Bundle prerequisites remain unavailable after explicit history recovery. Provision the required objects during authenticated checkout."); } /** @@ -728,9 +706,8 @@ function extractBundlePrerequisiteCommits(message) { * filter so the server sends the missing blobs for the reachable range — and * nothing beyond what those anchor commits reach. * - * This mirrors the targeted, bounded fetch-by-SHA strategy used as the primary - * path in `ensureFullHistoryForBundle`, keeping a single, unified approach to - * "the objects we need aren't present in this shallow/partial clone". + * Fetch-by-SHA is not a byte bound: the reachable history can be large. This + * helper is for signed-push recovery, not default bundle prerequisite recovery. * * @param {{ getExecOutput: Function }} execApi - Exec API to run git commands. * @param {Array} commitShas - Anchor commit SHAs whose object content must be present. @@ -899,6 +876,9 @@ module.exports = { backfillCommitObjects, checkImplausibleShallowRange, ensureFullHistoryForBundle, + importLocalBundle, + BUNDLE_HISTORY_ERROR, + offlineGitOptions, ensureOriginRemoteTrackingRef, extractBundlePrerequisiteCommits, getBundlePrerequisites, diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index b9fc2047084..6714e2b2590 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -454,7 +454,7 @@ describe("git_helpers.cjs", () => { }); describe("ensureFullHistoryForBundle", () => { - it("should unshallow the repository when the repository is shallow", async () => { + it("refuses legacy history fetches without a local bundle path", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const execApi = { getExecOutput: vi.fn().mockResolvedValue({ stdout: "true\n" }), @@ -462,10 +462,9 @@ describe("git_helpers.cjs", () => { }; const options = { cwd: "/tmp/repo" }; - await ensureFullHistoryForBundle(execApi, options); - - expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["rev-parse", "--is-shallow-repository"], options); - expect(execApi.exec).toHaveBeenCalledWith("git", ["fetch", "--unshallow", "origin"], options); + await expect(ensureFullHistoryForBundle(execApi, options)).rejects.toThrow("no history was fetched"); + expect(execApi.getExecOutput).not.toHaveBeenCalled(); + expect(execApi.exec).not.toHaveBeenCalled(); }); it("should not fetch full history when the repository is not shallow", async () => { @@ -475,12 +474,12 @@ describe("git_helpers.cjs", () => { exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi); + await expect(ensureFullHistoryForBundle(execApi)).rejects.toThrow("local bundle path"); expect(execApi.exec).not.toHaveBeenCalled(); }); - it("should skip history probing when shallow status cannot be determined", async () => { + it("does not probe history when no bundle path is supplied", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const warning = mockCoreWarning(); const execApi = { @@ -488,14 +487,13 @@ describe("git_helpers.cjs", () => { exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi); + await expect(ensureFullHistoryForBundle(execApi)).rejects.toThrow("local bundle path"); expect(execApi.exec).not.toHaveBeenCalled(); - expect(warning).toHaveBeenCalledTimes(1); - expect(warning).toHaveBeenCalledWith("Could not determine shallow repository status; skipping full-history fetch probe: not a git repository"); + expect(warning).not.toHaveBeenCalled(); }); - it("should warn with stringified non-error shallow status failures", async () => { + it("does not expose raw failures from unused history probes", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const warning = mockCoreWarning(); const execApi = { @@ -503,26 +501,17 @@ describe("git_helpers.cjs", () => { exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi); + await expect(ensureFullHistoryForBundle(execApi)).rejects.toThrow("local bundle path"); expect(execApi.exec).not.toHaveBeenCalled(); - expect(warning).toHaveBeenCalledTimes(1); - expect(warning).toHaveBeenCalledWith("Could not determine shallow repository status; skipping full-history fetch probe: unknown failure"); + expect(warning).not.toHaveBeenCalled(); }); - it("should fetch prerequisite commit SHAs directly from origin when known and shallow", async () => { + it("fails actionably without downloading missing prerequisites", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const prereq = "a".repeat(40); - let prereqFetched = false; const execApi = { getExecOutput: vi.fn().mockImplementation((cmd, args) => { - if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { - return Promise.resolve({ stdout: "true\n", exitCode: 0 }); - } - if (args[0] === "config") { - // sparse-checkout not set - return Promise.resolve({ stdout: "", exitCode: 1 }); - } if (args[0] === "bundle" && args[1] === "verify") { return Promise.resolve({ stdout: "", @@ -531,68 +520,46 @@ describe("git_helpers.cjs", () => { }); } if (args[0] === "cat-file" && args[1] === "-e") { - // Object is present only after the direct SHA fetch. - return Promise.resolve({ exitCode: prereqFetched ? 0 : 1, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), - exec: vi.fn().mockImplementation((cmd, args) => { - if (args && args[0] === "fetch" && args.includes("origin") && args.includes(prereq)) { - prereqFetched = true; - } - return Promise.resolve(0); - }), + exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" }); + await expect(ensureFullHistoryForBundle(execApi, {}, { bundleFilePath: "/tmp/test.bundle" })).rejects.toThrow("allow-full-history-fetch: true"); - // Direct SHA fetch satisfies the prerequisite; no deepen, no --unshallow. const fetchCalls = execApi.exec.mock.calls.filter(c => c[1] && c[1][0] === "fetch"); - expect(fetchCalls.length).toBe(1); - expect(fetchCalls[0][1]).toEqual(["fetch", "--filter=blob:none", "origin", prereq]); + expect(fetchCalls.length).toBe(0); expect(execApi.exec).not.toHaveBeenCalledWith("git", expect.arrayContaining(["--unshallow"]), expect.anything()); }); - it("should fall back to deepening by 5 commits at a time when direct SHA fetch is insufficient", async () => { + it("never iteratively deepens or unshallows by default", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const prereq = "c".repeat(40); - let deepenCalls = 0; const execApi = { getExecOutput: vi.fn().mockImplementation((cmd, args) => { - if (args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { - return Promise.resolve({ stdout: "true\n", exitCode: 0 }); - } - if (args[0] === "config") { - return Promise.resolve({ stdout: "", exitCode: 1 }); - } if (args[0] === "bundle" && args[1] === "verify") { return Promise.resolve({ stdout: "", stderr: `The bundle requires this ref:\n${prereq}\n`, exitCode: 1 }); } if (args[0] === "cat-file" && args[1] === "-e") { - // Present only after the second deepen fetch; direct SHA fetch leaves it missing. - return Promise.resolve({ exitCode: deepenCalls >= 2 ? 0 : 1, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); } return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); }), - exec: vi.fn().mockImplementation((cmd, args) => { - if (args && args[0] === "fetch" && args[1] && args[1].startsWith("--deepen=")) { - deepenCalls++; - } - return Promise.resolve(0); - }), + exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" }); + await expect(ensureFullHistoryForBundle(execApi, {}, { bundleFilePath: "/tmp/test.bundle" })).rejects.toThrow("disabled by default"); const deepenFetchCalls = execApi.exec.mock.calls.filter(c => c[1] && c[1][0] === "fetch" && c[1][1] && c[1][1].startsWith("--deepen=")); - expect(deepenFetchCalls.length).toBe(2); - // Each deepen step is a small, fixed increment of 5. - expect(deepenFetchCalls[0][1]).toEqual(["fetch", "--deepen=5", "origin", "main"]); + expect(deepenFetchCalls.length).toBe(0); expect(execApi.exec).not.toHaveBeenCalledWith("git", expect.arrayContaining(["--unshallow"]), expect.anything()); }); it("should skip deepening when bundle declares no prerequisites", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); + const withRecoveryCredentials = vi.fn(); const execApi = { getExecOutput: vi.fn().mockImplementation((cmd, args) => { if (args[0] === "rev-parse") return Promise.resolve({ stdout: "true\n" }); @@ -604,14 +571,16 @@ describe("git_helpers.cjs", () => { exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" }); + await ensureFullHistoryForBundle(execApi, {}, { bundleFilePath: "/tmp/test.bundle", allowFullHistoryFetch: true, withRecoveryCredentials }); expect(execApi.exec).not.toHaveBeenCalled(); + expect(withRecoveryCredentials).not.toHaveBeenCalled(); }); it("should skip fetching when prereqs are already present locally", async () => { const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); const prereq = "b".repeat(40); + const withRecoveryCredentials = vi.fn(); const execApi = { getExecOutput: vi.fn().mockImplementation((cmd, args) => { if (args[0] === "rev-parse") return Promise.resolve({ stdout: "true\n" }); @@ -626,9 +595,82 @@ describe("git_helpers.cjs", () => { exec: vi.fn().mockResolvedValue(0), }; - await ensureFullHistoryForBundle(execApi, {}, { baseRef: "main", bundleFilePath: "/tmp/test.bundle" }); + await ensureFullHistoryForBundle(execApi, {}, { bundleFilePath: "/tmp/test.bundle", allowFullHistoryFetch: true, withRecoveryCredentials }); expect(execApi.exec).not.toHaveBeenCalled(); + expect(withRecoveryCredentials).not.toHaveBeenCalled(); + }); + + it.each(["true", "false"])("uses the selected recovery remote and credential scope (shallow: %s)", async shallow => { + const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); + const prereq = "d".repeat(40); + const remote = "https://github.com/test-owner/automation-fork.git"; + let recovered = false; + let credentialScope = false; + const execApi = { + getExecOutput: vi.fn(async (_cmd, args, opts) => { + if (args[0] === "bundle") return { exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${prereq}\n` }; + if (args[0] === "cat-file") { + expect(opts.env.GIT_ALLOW_PROTOCOL).toBe(""); + return { exitCode: recovered ? 0 : 1, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse") return { exitCode: 0, stdout: shallow, stderr: "" }; + if (args[0] === "fetch") { + expect(credentialScope).toBe(true); + expect(opts.silent).toBe(true); + // A fork-only prerequisite can still be absent after unshallowing. + if (args[1] === remote) recovered = true; + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error("Unexpected command"); + }), + }; + const withRecoveryCredentials = vi.fn(async fetch => { + credentialScope = true; + try { + await fetch(); + } finally { + credentialScope = false; + } + }); + await ensureFullHistoryForBundle(execApi, {}, { bundleFilePath: "test.bundle", allowFullHistoryFetch: true, remote, withRecoveryCredentials }); + expect(withRecoveryCredentials).toHaveBeenCalledOnce(); + expect(credentialScope).toBe(false); + expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["fetch", remote, prereq], expect.any(Object)); + const unshallowCalls = execApi.getExecOutput.mock.calls.filter(([, args]) => args.includes("--unshallow")); + expect(unshallowCalls.map(([, args]) => args)).toEqual(shallow === "true" ? [["fetch", "--unshallow", remote]] : []); + expect(execApi.getExecOutput.mock.calls.some(([, args]) => args.includes("origin"))).toBe(false); + }); + + it.each([false, undefined, "true", 1])("never opens recovery credentials without literal opt-in (%s)", async allowFullHistoryFetch => { + const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); + const withRecoveryCredentials = vi.fn(); + const execApi = { + getExecOutput: vi.fn(async (_cmd, args) => ({ exitCode: 1, stdout: "", stderr: args[0] === "bundle" ? `The bundle requires this ref:\n${"e".repeat(40)}\n` : "" })), + }; + await expect(ensureFullHistoryForBundle(execApi, {}, { bundleFilePath: "test.bundle", allowFullHistoryFetch, remote: "fork", withRecoveryCredentials })).rejects.toThrow("disabled by default"); + expect(withRecoveryCredentials).not.toHaveBeenCalled(); + expect(execApi.getExecOutput.mock.calls.some(([, args]) => args[0] === "fetch")).toBe(false); + }); + + it("redacts credential-scope failures during opted-in recovery", async () => { + const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs"); + const execApi = { + getExecOutput: vi.fn(async (_cmd, args) => ({ exitCode: 1, stdout: "", stderr: args[0] === "bundle" ? `The bundle requires this ref:\n${"e".repeat(40)}\n` : "" })), + }; + await expect( + ensureFullHistoryForBundle( + execApi, + {}, + { + bundleFilePath: "test.bundle", + allowFullHistoryFetch: true, + withRecoveryCredentials: async () => { + throw new Error("synthetic private credential"); + }, + } + ) + ).rejects.toThrow("Explicit bundle history recovery failed. Provision the required objects during authenticated checkout with credentials for the recovery repository."); }); }); @@ -861,7 +903,7 @@ describe("git_helpers.cjs", () => { const result = await getBundlePrerequisites(execApi, "/tmp/test.bundle"); expect(result).toEqual([PREREQ_SHA]); - expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "verify", "/tmp/test.bundle"], { ignoreReturnCode: true, silent: true }); + expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "verify", "/tmp/test.bundle"], expect.objectContaining({ ignoreReturnCode: true, silent: true, env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "" }) })); }); it("should return multiple prerequisite SHAs when bundle has several", async () => { @@ -942,7 +984,11 @@ describe("git_helpers.cjs", () => { await getBundlePrerequisites(execApi, "/tmp/test.bundle", options); - expect(execApi.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "verify", "/tmp/test.bundle"], { cwd: "/repo", ignoreReturnCode: true, silent: true }); + expect(execApi.getExecOutput).toHaveBeenCalledWith( + "git", + ["bundle", "verify", "/tmp/test.bundle"], + expect.objectContaining({ cwd: "/repo", ignoreReturnCode: true, silent: true, env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "" }) }) + ); }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index caacb52420c..ab70e5eb9a9 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -21,7 +21,7 @@ const { POLICY_FILE_PROTECTION_DENIED_REASON_CODE } = require("./error_codes.cjs const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs"); const { withGitHubHostToken } = require("./git_auth_helpers.cjs"); -const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs"); +const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs"); const { extractPatchBaseCommit } = require("./commit_sha_helpers.cjs"); const { findRepoCheckout } = require("./find_repo_checkout.cjs"); const { getThreatWarningPresentation } = require("./threat_detection_warning.cjs"); @@ -122,7 +122,7 @@ function parsePositiveInteger(value) { * @returns {Promise} */ async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef, conflictedFiles = []) { - const bundleDiffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", "-z", `${rangeBaseRef}..${bundleRef}`], gitOptions); + const bundleDiffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", "-z", `${rangeBaseRef}..${bundleRef}`], offlineGitOptions(gitOptions)); return [...new Set([...bundleDiffResult.stdout.split("\0").filter(Boolean), ...conflictedFiles])]; } @@ -142,7 +142,7 @@ async function getBundleValidationBase(exec, gitOptions, oldHead, candidate, tru const fallback = { ref: oldHead, conflictedFiles: [] }; if (!trustedBase) return fallback; try { - const opts = { ...gitOptions, ignoreReturnCode: true }; + const opts = { ...offlineGitOptions(gitOptions), ignoreReturnCode: true }; const oldAncestor = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", oldHead, candidate], opts); const baseAncestor = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", trustedBase, candidate], opts); const alreadyMerged = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", trustedBase, oldHead], opts); @@ -165,20 +165,38 @@ async function getBundleValidationBase(exec, gitOptions, oldHead, candidate, tru return fallback; } -async function fetchBundlePrerequisites(exec, core, gitAuthEnv, baseGitOpts, prerequisiteCommits, logPrefix = "") { - core.warning(`${logPrefix}bundle fetch failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`); - core.info(`${logPrefix}fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`); - // Use --filter=blob:none only when the local repo is already shallow or sparse — - // in a full clone we already have all blobs and must not convert the repo to a - // partial clone (which would trigger lazy blob fetches on later operations). - const prereqGitOpts = { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts }; - const useBlobFilter = await isShallowOrSparseCheckout(exec, prereqGitOpts); - const prerequisiteFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...prerequisiteCommits] : ["fetch", "origin", ...prerequisiteCommits]; - if (useBlobFilter) { - core.info(`${logPrefix}using --filter=blob:none for prerequisite fetch (shallow or sparse checkout detected)`); +/** + * Resolve exactly one trusted base branch tip without transferring objects. + * The target checkout's origin credentials must remain in effect, not the fork + * token temporarily installed for PR-head operations. + * + * @param {{ getExecOutput: Function }} exec + * @param {Object} gitOptions + * @param {unknown} baseRef + * @returns {Promise} + */ +async function resolveTrustedBaseTip(exec, gitOptions, baseRef) { + if (typeof baseRef !== "string" || !baseRef) { + core.debug("Bundle validation: base branch metadata unavailable; using PR-head validation"); + return ""; + } + const ref = `refs/heads/${baseRef}`; + const opts = { ...gitOptions, env: { ...process.env, ...gitOptions.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "Never", GIT_ASKPASS: "/bin/echo" }, silent: true, ignoreReturnCode: true }; + try { + const valid = await exec.getExecOutput("git", ["check-ref-format", ref], { ...offlineGitOptions(gitOptions), ignoreReturnCode: true }); + if (valid.exitCode !== 0) throw new Error("Invalid base ref"); + const result = await exec.getExecOutput("git", ["ls-remote", "--refs", "--exit-code", "origin", ref], opts); + const lines = result.stdout.trim().split("\n"); + const fields = lines[0].split("\t"); + if (result.exitCode === 0 && lines.length === 1 && fields.length === 2 && /^[0-9a-f]{40}$/.test(fields[0]) && fields[1] === ref) { + core.debug("Bundle validation: pinned trusted base branch tip from remote metadata"); + return fields[0]; + } + } catch { + // Neither command errors nor remote diagnostics are safe to log. } - await exec.exec("git", prerequisiteFetchArgs, prereqGitOpts); - core.info(`${logPrefix}fetched prerequisite commits from origin successfully`); + core.warning("Unable to resolve trusted PR base metadata; using PR-head validation"); + return ""; } /** @@ -1097,110 +1115,42 @@ async function main(config = {}) { } if (hasBundleFile) { - // Bundle transport: fetch commits directly from the bundle file. + // Bundle transport: import commits directly from the local bundle file. // This preserves merge commit topology and per-commit metadata. core.info(`Applying changes from bundle: ${bundleFilePath}`); const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`; try { - // Resolve from GitHub metadata and fetch from the target checkout's - // origin, never from agent-supplied refs or patch headers. - let trustedBase = ""; - const baseSha = pullRequest.base?.sha; - if (!signedCommits && typeof baseSha === "string" && /^[0-9a-f]{40}$/.test(baseSha)) { - try { - await exec.exec("git", ["fetch", "origin", baseSha], { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts }); - trustedBase = baseSha; - } catch (baseFetchError) { - core.warning(`Unable to fetch trusted PR base; using PR-head validation: ${getErrorMessage(baseFetchError)}`); - } - } + const trustedBase = signedCommits ? "" : await resolveTrustedBaseTip(exec, baseGitOpts, pullRequest.base?.ref); + const bundleGitOpts = offlineGitOptions(baseGitOpts); await ensureFullHistoryForBundle( exec, { env: { ...process.env, ...gitAuthEnv }, ...baseGitOpts, }, - { baseRef: branchName, bundleFilePath } + { + bundleFilePath, + allowFullHistoryFetch: config.allow_full_history_fetch === true, + remote: branchRemoteName, + withRecoveryCredentials: fetch => withGitHubHostToken(headGitHubToken, fetch, baseGitOpts.cwd), + } ); - // Fetch from bundle into a temporary ref. - // Use getExecOutput with ignoreReturnCode so we can read the actual stderr from git — - // exec() only throws "The process '...' failed with exit code 1" which loses the - // "lacks these prerequisite commits" text needed for the recovery path below. - const bundleFetchRef = `refs/heads/${branchName}:${bundleRef}`; - const initialBundleFetch = await exec.getExecOutput("git", ["fetch", bundleFilePath, bundleFetchRef], { ...baseGitOpts, ignoreReturnCode: true }); - if (initialBundleFetch.exitCode !== 0) { - const initialFetchErrorOutput = initialBundleFetch.stderr || `exit code ${initialBundleFetch.exitCode}`; - - // Recovery path for bundle prerequisite failures: fetch missing prerequisite - // commit objects, then retry with the original bundle ref. - // This handles the race where main advanced between agent-time and safe_outputs-time: - // the bundle's base commit may not be reachable from a fetch-depth:1 shallow clone - // (e.g. when the commit is on a ref not in the fetch refspec). - const prerequisiteCommits = extractBundlePrerequisiteCommits(initialFetchErrorOutput); - if (prerequisiteCommits.length > 0) { - await fetchBundlePrerequisites(exec, core, gitAuthEnv, baseGitOpts, prerequisiteCommits); - await exec.exec("git", ["fetch", bundleFilePath, bundleFetchRef], baseGitOpts); - core.info("Bundle fetch retry succeeded after prerequisite recovery"); - } else { - core.warning(`Bundle fetch from refs/heads/${branchName} failed: ${initialFetchErrorOutput}; resolving source ref from bundle heads`); - const { stdout: bundleHeadsOutput } = await exec.getExecOutput("git", ["bundle", "list-heads", bundleFilePath], baseGitOpts); - const bundleHeads = bundleHeadsOutput - .split("\n") - .map(line => line.trim().split(/\s+/)) - // Bundles produced here advertise SHA-1 object IDs; reject malformed entries. - .filter(parts => parts.length === 2 && /^[0-9a-f]{40}$/.test(parts[0]) && parts[1]); - const branchRefChecks = await Promise.all( - bundleHeads - .filter(([, ref]) => ref.startsWith("refs/heads/")) - .map(async ([, ref]) => ({ - ref, - isValid: (await exec.getExecOutput("git", ["check-ref-format", ref], { ...baseGitOpts, ignoreReturnCode: true })).exitCode === 0, - })) - ); - const branchRefs = branchRefChecks.filter(({ isValid }) => isValid).map(({ ref }) => ref); - - let bundleSourceRef; - if (branchRefs.length === 1) { - bundleSourceRef = branchRefs[0]; - } else if (branchRefs.length === 0) { - const headRefs = bundleHeads.filter(([, ref]) => ref === "HEAD"); - if (headRefs.length !== 1) { - throw new Error(`Failed to resolve bundle source ref from list-heads: expected exactly 1 HEAD entry, found ${headRefs.length}`); - } - bundleSourceRef = "HEAD"; - } else { - throw new Error(`Failed to resolve bundle source ref from list-heads: expected exactly 1 refs/heads entry, found ${branchRefs.length}`); - } - - core.info(`Fetching resolved bundle source ${bundleSourceRef} into ${bundleRef}`); - const resolvedBundleFetchRef = `${bundleSourceRef}:${bundleRef}`; - const resolvedBundleFetch = await exec.getExecOutput("git", ["fetch", bundleFilePath, resolvedBundleFetchRef], { ...baseGitOpts, ignoreReturnCode: true }); - if (resolvedBundleFetch.exitCode !== 0) { - const resolvedFetchErrorOutput = resolvedBundleFetch.stderr || `exit code ${resolvedBundleFetch.exitCode}`; - const resolvedPrerequisiteCommits = extractBundlePrerequisiteCommits(resolvedFetchErrorOutput); - if (resolvedPrerequisiteCommits.length === 0) { - throw new Error(`Failed to fetch resolved bundle source ${bundleSourceRef}: ${resolvedFetchErrorOutput}`); - } - - await fetchBundlePrerequisites(exec, core, gitAuthEnv, baseGitOpts, resolvedPrerequisiteCommits, "[resolved] "); - await exec.exec("git", ["fetch", bundleFilePath, resolvedBundleFetchRef], baseGitOpts); - core.info("Resolved bundle fetch retry succeeded after prerequisite recovery"); - } - } - } - core.info(`Fetched bundle to ${bundleRef}`); + await importLocalBundle(exec, bundleFilePath, `refs/heads/${branchName}`, bundleRef, baseGitOpts); + core.info(`Imported local bundle to ${bundleRef}`); const validationBase = await getBundleValidationBase(exec, baseGitOpts, rangeBaseRef, bundleRef, trustedBase); validationBaseRef = validationBase.ref; validationConflictFiles = validationBase.conflictedFiles; const trustedBaseInValidationBase = validationBaseRef !== rangeBaseRef; if (trustedBaseInValidationBase) { - core.info(`Bundle validation baseline includes trusted base ${trustedBase}: ${validationBaseRef}`); + core.info("Bundle validation baseline includes trusted base changes"); + } else { + core.info("Bundle validation baseline uses PR head"); } const agentHistoryExclusions = [rangeBaseRef]; if (trustedBaseInValidationBase) agentHistoryExclusions.push(trustedBase); if (deferBundleValidation) { - const agentPatchStream = await exec.getExecOutput("git", ["log", "--format=", "--patch", "--binary", "--no-ext-diff", "--no-textconv", "--diff-merges=remerge", bundleRef, "--not", ...agentHistoryExclusions], baseGitOpts); + const agentPatchStream = await exec.getExecOutput("git", ["log", "--format=", "--patch", "--binary", "--no-ext-diff", "--no-textconv", "--diff-merges=remerge", bundleRef, "--not", ...agentHistoryExclusions], bundleGitOpts); const validation = validatePatch(agentPatchStream.stdout); if (validation.error) return validation.error; if (validation.fallbackFiles.length) return await createProtectedFilesFallbackIssue(validation.fallbackFiles); @@ -1212,15 +1162,15 @@ async function main(config = {}) { // patch artifact under-detects files (for example, merge-resolution // content preserved only by the bundle transport). { - let bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, validationBaseRef, bundleRef, validationConflictFiles); + let bundleFiles = await getBundlePreApplyFiles(exec, bundleGitOpts, validationBaseRef, bundleRef, validationConflictFiles); if (deferBundleValidation) { - const octopus = await exec.getExecOutput("git", ["rev-list", "--min-parents=3", bundleRef, "--not", ...agentHistoryExclusions], baseGitOpts); + const octopus = await exec.getExecOutput("git", ["rev-list", "--min-parents=3", bundleRef, "--not", ...agentHistoryExclusions], bundleGitOpts); if (octopus.stdout.trim()) { return { success: false, error: "Cannot validate agent-created octopus merge commits in the bundle. Use two-parent merges instead." }; } // Preserve protection for agent commits that change then revert a // file, without attributing trusted base-branch commits to the agent. - const history = await exec.getExecOutput("git", ["log", "--format=", "--name-only", "--no-renames", "--diff-merges=remerge", "-z", bundleRef, "--not", ...agentHistoryExclusions], baseGitOpts); + const history = await exec.getExecOutput("git", ["log", "--format=", "--name-only", "--no-renames", "--diff-merges=remerge", "-z", bundleRef, "--not", ...agentHistoryExclusions], bundleGitOpts); bundleFiles = [...new Set([...bundleFiles, ...history.stdout.split("\0").filter(Boolean)])]; } if (bundleFiles.length > 0) { @@ -1252,7 +1202,7 @@ async function main(config = {}) { updateRefArgs.push(remoteHeadBeforePatch); } await exec.exec("git", updateRefArgs, baseGitOpts); - await exec.exec("git", ["reset", "--hard"], baseGitOpts); + await exec.exec("git", ["reset", "--hard"], bundleGitOpts); core.info(`Updated branch to bundle tip from ${bundleRef}`); // Clean up the temporary ref @@ -1388,7 +1338,7 @@ async function main(config = {}) { // (see github/agentic-workflows#539) let agentChangedFiles = []; { - const diffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", "-z", `${validationBaseRef}..HEAD`], baseGitOpts); + const diffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", "-z", `${validationBaseRef}..HEAD`], hasBundleFile ? offlineGitOptions(baseGitOpts) : baseGitOpts); const actualFiles = [...new Set([...diffResult.stdout.split("\0").filter(Boolean), ...validationConflictFiles])]; agentChangedFiles = actualFiles; if (actualFiles.length > 0) { @@ -1869,4 +1819,4 @@ async function main(config = {}) { }); } -module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles, getBundleValidationBase }; +module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles, getBundleValidationBase, resolveTrustedBaseTip }; 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 603e3da7ee9..17da0204bdb 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -125,7 +125,7 @@ describe("push_to_pull_request_branch.cjs", () => { // Default exec mock mockExec = { exec: vi.fn().mockResolvedValue(0), - getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }), + getExecOutput: vi.fn().mockImplementation((_cmd, args) => Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" })), }; // Default pull request context @@ -2351,7 +2351,9 @@ index 0000000..abc1234 fs.writeFileSync(changedPath, Buffer.alloc(contentSize)); const pr = (await mockGithub.rest.pulls.get()).data; pr.base.sha = trustedBase; + pr.base.ref = "main"; mockExec.getExecOutput.mockImplementation(async (_cmd, args) => { + if (args[0] === "ls-remote" && args.includes("--refs")) return { exitCode: 0, stdout: `${trustedBase}\trefs/heads/main\n`, stderr: "" }; if (args[0] === "merge-base") { return { exitCode: args[3] === "remote-head" ? 1 : 0, stdout: "", stderr: "" }; } @@ -2364,7 +2366,7 @@ index 0000000..abc1234 if (args[0] === "log" && args.includes("--patch")) return { exitCode: 0, stdout: historyPatch, stderr: "" }; if (args[0] === "log" && args[1] === "--format=") return { exitCode: 0, stdout: historyFile ? historyFile + "\0" : "", stderr: "" }; if (args[0] === "rev-list" && args[1] === "--min-parents=3") return { exitCode: 0, stdout: octopusMerge ? "c".repeat(40) + "\n" : "", stderr: "" }; - return { exitCode: 0, stdout: "remote-head\n", stderr: "" }; + return { exitCode: 0, stdout: args[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "remote-head\n", stderr: "" }; }); const pushModule = require("./push_signed_commits.cjs"); const pushSpy = vi.spyOn(pushModule, "pushSignedCommits").mockResolvedValue("candidate-head"); @@ -2381,7 +2383,8 @@ index 0000000..abc1234 expect(pushSpy).toHaveBeenCalledWith(expect.objectContaining({ baseRef: "remote-head", signedCommits: false })); expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["diff", "--name-only", "--no-renames", "-z", `${validationTree}..HEAD`], expect.any(Object)); } - expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", trustedBase], expect.any(Object)); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "--refs", "--exit-code", "origin", "refs/heads/main"], expect.objectContaining({ silent: true })); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "origin", trustedBase], expect.any(Object)); if (expectedError === "bundle modifies protected files") { const historyExclusions = mergeTreeFailure ? ["remote-head"] : ["remote-head", trustedBase]; expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["log", "--format=", "--name-only", "--no-renames", "--diff-merges=remerge", "-z", "refs/bundles/push-feature-branch", "--not", ...historyExclusions], expect.any(Object)); @@ -2424,17 +2427,16 @@ index 0000000..abc1234 }); }); - it("retains the preview size limit when the trusted base fetch fails", async () => { + it("retains the preview size limit when trusted base metadata is unavailable", async () => { const branch = "merge-base-fetch-fails"; createPatchFile(branch, "x".repeat(4096)); fs.writeFileSync(canonicalBundlePath(branch), "bundle content"); const pr = (await mockGithub.rest.pulls.get()).data; pr.base.sha = "b".repeat(40); - mockExec.exec.mockImplementation(async (_cmd, args) => { - if (args[0] === "fetch" && args[2] === pr.base.sha) throw new Error("base unavailable"); - return 0; - }); + pr.base.ref = "main"; mockExec.getExecOutput.mockImplementation(async (_cmd, args) => { + if (args[0] === "ls-remote" && args.includes("--refs")) return { exitCode: 128, stdout: "", stderr: "synthetic credential-bearing diagnostic" }; + if (args[0] === "bundle" && args[1] === "unbundle") return { exitCode: 0, stdout: `${"a".repeat(40)} HEAD\n`, stderr: "" }; if (args[0] === "log" && args.includes("--patch")) return { exitCode: 0, stdout: "x".repeat(4096), stderr: "" }; return { exitCode: 0, stdout: "", stderr: "" }; }); @@ -2443,6 +2445,8 @@ index 0000000..abc1234 const result = await handler({ branch, diff_size: 0 }, {}); expect(result.success).toBe(false); expect(result.error).toContain("Patch size"); + expect(mockCore.warning).toHaveBeenCalledWith("Unable to resolve trusted PR base metadata; using PR-head validation"); + expect(JSON.stringify(mockCore.warning.mock.calls)).not.toContain("synthetic"); expect(mockExec.exec.mock.calls.some(([, args]) => args[0] === "update-ref" && args[1] === "refs/heads/feature-branch")).toBe(false); }); @@ -2462,7 +2466,7 @@ index 0000000..abc1234 it("should accept patches within max size", async () => { const patchPath = createPatchFile("should-accept-patches-within-max-size"); // Small valid patch - mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + mockExec.getExecOutput.mockImplementation(async (_cmd, args) => ({ exitCode: 0, stdout: args[0] === "bundle" && args[1] === "unbundle" ? `${"a".repeat(40)} HEAD\n` : "abc123\n", stderr: "" })); const module = await loadModule(); const handler = await module.main({ max_patch_size: 1024 }); @@ -2531,7 +2535,7 @@ index 0000000..abc1234 const patchPath = createPatchFile("should-prefer-diff-size-over-bundle-file-size-for-the-limit-", "small patch content"); fs.writeFileSync(bundlePath, Buffer.alloc(2 * 1024 * 1024)); - mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + mockExec.getExecOutput.mockImplementation(async (_cmd, args) => ({ exitCode: 0, stdout: args[0] === "bundle" && args[1] === "unbundle" ? `${"a".repeat(40)} HEAD\n` : "abc123\n", stderr: "" })); const module = await loadModule(); const handler = await module.main({ max_patch_size: 1024 }); // 1 MB max @@ -2583,7 +2587,7 @@ index 0000000..abc1234 try { const prereqSha = "a".repeat(40); - let prereqFetched = false; + const prereqFetched = true; mockExec.getExecOutput.mockImplementation((cmd, args, options) => { if (cmd === "git" && args[0] === "ls-remote") { return Promise.resolve({ exitCode: 0, stdout: "remote-head\trefs/heads/feature-branch\n", stderr: "" }); @@ -2607,30 +2611,26 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); - }); - mockExec.exec.mockImplementation((cmd, args) => { - if (cmd === "git" && Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereqSha)) { - prereqFetched = true; - } - return Promise.resolve(0); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); - const module = await loadModule(); const handler = await module.main({}); const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); expect(result.success).toBe(true); // Initial bundle fetch is via getExecOutput (with ignoreReturnCode: true), not exec.exec - const bundleFetchCall = mockExec.getExecOutput.mock.calls.find(([, args, options]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode); + const bundleFetchCall = mockExec.getExecOutput.mock.calls.find( + ([, args, options]) => Array.isArray(args) && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath) && options && options.ignoreReturnCode + ); expect(bundleFetchCall).toBeDefined(); - expect(bundleFetchCall[1]).toEqual(["fetch", bundleSnapshot(bundlePath), "refs/heads/feature-branch:refs/bundles/push-feature-branch"]); + expect(bundleFetchCall[1]).toEqual(["bundle", "unbundle", bundleSnapshot(bundlePath)]); + expect(bundleFetchCall[2].env.GIT_ALLOW_PROTOCOL).toBe(""); expect(mockExec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/heads/feature-branch", "refs/bundles/push-feature-branch", "remote-head"], expect.any(Object)); expect(mockExec.exec).toHaveBeenCalledWith("git", ["reset", "--hard"], expect.any(Object)); expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["merge", "--ff-only", "refs/bundles/push-feature-branch"], expect.any(Object)); - // Primary path: the exact prerequisite SHA is fetched directly from origin; no broad deepen. + // Already-provisioned prerequisites never trigger a recovery download. const directFetch = mockExec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && args.includes("origin") && args.includes(prereqSha)); - expect(directFetch).toBeTruthy(); + expect(directFetch).toBeUndefined(); const deepenCallIndex = mockExec.exec.mock.calls.findIndex(([, args]) => Array.isArray(args) && args[0] === "fetch" && typeof args[1] === "string" && args[1].startsWith("--deepen=")); expect(deepenCallIndex).toBe(-1); } finally { @@ -2676,7 +2676,7 @@ index 0000000..abc1234 return Promise.resolve({ exitCode: 0, stdout: "false\n", stderr: "" }); } if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } if (cmd === "git" && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--no-renames") { return Promise.resolve({ exitCode: 0, stdout: `${actualFiles.join("\0")}\0`, stderr: "" }); @@ -2684,7 +2684,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -2726,7 +2726,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -2737,18 +2737,16 @@ index 0000000..abc1234 expect(result.success).toBe(true); // Bundle fetch must use the sanitized API branch name, not the agent-supplied message.branch - const bundleFetchCall = mockExec.getExecOutput.mock.calls.find(([, args, opts]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && opts && opts.ignoreReturnCode); - expect(bundleFetchCall).toBeDefined(); - // Refspec must use the sanitized name "feature-branch", not "feature-branch; rm -rf /" - expect(bundleFetchCall[1][2]).toMatch(/^refs\/heads\/feature-branch:/); - expect(bundleFetchCall[1][2]).not.toContain(";"); - expect(bundleFetchCall[1][2]).not.toContain("rm"); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "unbundle", bundleSnapshot(bundlePath)], expect.objectContaining({ silent: true })); + const update = mockExec.exec.mock.calls.find(([, args]) => args[0] === "update-ref" && args[1] === "refs/heads/feature-branch"); + expect(update).toBeDefined(); + expect(update[1][1]).not.toContain(";"); } finally { pushSignedSpy.mockRestore(); } }); - it("should fetch a HEAD-only filtered bundle when the named branch ref is absent", async () => { + it("imports a HEAD-only bundle without prerequisite recovery downloads", async () => { const bundlePath = canonicalBundlePath("feature-branch"); const patchPath = createPatchFile("feature-branch", "small patch content"); fs.writeFileSync(bundlePath, "bundle content"); @@ -2772,7 +2770,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && args[2].startsWith("refs/heads/") && options && options.ignoreReturnCode) { return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); } - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle") { return Promise.resolve({ exitCode: 0, stdout: `${bundleHead} HEAD\n`, stderr: "" }); } if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && args[2].startsWith("HEAD:") && options && options.ignoreReturnCode) { @@ -2781,7 +2779,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -2789,10 +2787,9 @@ index 0000000..abc1234 const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); expect(result.success).toBe(true); - expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "list-heads", bundleSnapshot(bundlePath)], expect.any(Object)); - expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", bundleSnapshot(bundlePath), "HEAD:refs/bundles/push-feature-branch"], expect.objectContaining({ ignoreReturnCode: true })); - expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", prerequisiteSha], expect.any(Object)); - expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", bundleSnapshot(bundlePath), "HEAD:refs/bundles/push-feature-branch"], expect.any(Object)); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "unbundle", bundleSnapshot(bundlePath)], expect.objectContaining({ env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "" }) })); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/bundles/push-feature-branch", bundleHead], expect.any(Object)); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "--filter=blob:none", "origin", prerequisiteSha], expect.any(Object)); } finally { pushSignedSpy.mockRestore(); } @@ -2822,16 +2819,16 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && args[2].startsWith("refs/heads/feature-branch:") && options?.ignoreReturnCode) { return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); } - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle") { return Promise.resolve({ exitCode: 0, stdout: `${bundleHead} ${bundleSourceRef}\n`, stderr: "" }); } if (cmd === "git" && args[0] === "check-ref-format") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -2840,7 +2837,7 @@ index 0000000..abc1234 expect(result.success).toBe(true); expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["check-ref-format", bundleSourceRef], expect.objectContaining({ ignoreReturnCode: true })); - expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", bundleSnapshot(bundlePath), `${bundleSourceRef}:refs/bundles/push-feature-branch`], expect.objectContaining({ ignoreReturnCode: true })); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/bundles/push-feature-branch", bundleHead], expect.any(Object)); } finally { pushSignedSpy.mockRestore(); } @@ -2870,7 +2867,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && args[2].startsWith("refs/heads/feature-branch:") && options?.ignoreReturnCode) { return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); } - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle") { return Promise.resolve({ exitCode: 0, stdout: `${bundleHead} ${invalidBranchRef}\n${bundleHead} HEAD\n`, stderr: "" }); } if (cmd === "git" && args[0] === "check-ref-format") { @@ -2879,7 +2876,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "1\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -2887,7 +2884,7 @@ index 0000000..abc1234 const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); expect(result.success).toBe(true); - expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", bundleSnapshot(bundlePath), "HEAD:refs/bundles/push-feature-branch"], expect.objectContaining({ ignoreReturnCode: true })); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["update-ref", "refs/bundles/push-feature-branch", bundleHead], expect.any(Object)); } finally { pushSignedSpy.mockRestore(); } @@ -2914,13 +2911,13 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && args[2].startsWith("refs/heads/feature-branch:") && options?.ignoreReturnCode) { return Promise.resolve({ exitCode: 128, stdout: "", stderr: "fatal: couldn't find remote ref refs/heads/feature-branch" }); } - if (cmd === "git" && args[0] === "bundle" && args[1] === "list-heads") { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle") { return Promise.resolve({ exitCode: 0, stdout: bundleSourceRefs.map(ref => `${bundleHead} ${ref}`).join("\n"), stderr: "" }); } if (cmd === "git" && args[0] === "check-ref-format") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -2928,13 +2925,13 @@ index 0000000..abc1234 const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); expect(result.success).toBe(false); - expect(result.error).toContain("expected exactly 1 refs/heads entry, found 2"); + expect(result.error).toContain("unique source ref"); } finally { pushSignedSpy.mockRestore(); } }); - it("should fetch prerequisite commits and retry bundle fetch when bundle lacks prerequisites", async () => { + it("fails with opt-in guidance rather than fetching missing bundle prerequisites", async () => { const bundlePath = canonicalBundlePath("feature-branch"); const patchPath = createPatchFile("feature-branch", "small patch content"); fs.writeFileSync(bundlePath, "bundle content"); @@ -2954,25 +2951,114 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-parse" && args[1] === "--is-shallow-repository") { return Promise.resolve({ exitCode: 0, stdout: "false\n", stderr: "" }); } - if (cmd === "git" && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath) && options && options.ignoreReturnCode) { + if (cmd === "git" && args[0] === "bundle" && args[1] === "unbundle" && isBundleSnapshot(args[2], bundlePath) && options && options.ignoreReturnCode) { // Initial bundle fetch fails with prerequisite error (the real race condition scenario) return Promise.resolve({ exitCode: 1, stderr: `error: Repository lacks these prerequisite commits:\nerror: ${missingSha}`, stdout: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); const handler = await module.main({}); const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {}); - expect(result.success).toBe(true); - // Repo is not shallow and not sparse in this scenario, so no --filter=blob:none is used: - // the local clone already has all blobs and we must not convert it to a partial clone. - expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", missingSha], expect.any(Object)); - // Should have retried the bundle fetch via exec after fetching prerequisites + expect(result.success).toBe(false); + expect(result.error).toContain("allow-full-history-fetch: true"); + expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "origin", missingSha], expect.any(Object)); const bundleRetryFetch = mockExec.exec.mock.calls.find(([, args]) => Array.isArray(args) && args[0] === "fetch" && isBundleSnapshot(args[1], bundlePath)); - expect(bundleRetryFetch).toBeDefined(); - expect(bundleRetryFetch[1]).toEqual(["fetch", bundleSnapshot(bundlePath), "refs/heads/feature-branch:refs/bundles/push-feature-branch"]); + expect(bundleRetryFetch).toBeUndefined(); + } finally { + pushSignedSpy.mockRestore(); + } + }); + + it.each([ + { fork: false, failRecovery: false }, + { fork: true, failRecovery: false }, + { fork: true, failRecovery: true }, + ])("scopes opted-in bundle recovery to PR-head credentials without changing target metadata (fork: $fork, failure: $failRecovery)", async ({ fork, failRecovery }) => { + const bundlePath = canonicalBundlePath("feature-branch"); + createPatchFile("feature-branch", "small patch content"); + fs.writeFileSync(bundlePath, "bundle content"); + const prerequisite = "b".repeat(40); + const candidate = "c".repeat(40); + const targetHeader = "Authorization: basic synthetic-target-checkout"; + const headToken = "synthetic-head-token"; + const headHeader = `Authorization: basic ${Buffer.from(`x-access-token:${headToken}`).toString("base64")}`; + const headRepo = fork ? "test-owner/automation-fork" : "test-owner/test-repo"; + const remote = fork ? "https://github.com/test-owner/automation-fork.git" : "origin"; + const head = { ref: "feature-branch", repo: { full_name: headRepo, fork } }; + mockContext.payload.pull_request.head = head; + mockGithub.rest.pulls.get.mockResolvedValue({ + data: { head, base: { ref: "main", repo: { full_name: "test-owner/test-repo" } }, title: "Recovery PR", labels: [] }, + }); + let header = targetHeader; + let recovered = false; + const pushSignedSpy = vi.spyOn(require("./push_signed_commits.cjs"), "pushSignedCommits").mockResolvedValue(candidate); + mockExec.exec.mockImplementation(async (_cmd, args, options) => { + if (args[0] === "config" && args.includes("--replace-all")) { + expect(options.silent).toBe(true); + header = args.at(-1); + } + return 0; + }); + mockExec.getExecOutput.mockImplementation(async (_cmd, args, options) => { + if (args[0] === "config") { + if (args.includes("--get-all")) return { exitCode: 0, stdout: header ? `${header}\n` : "", stderr: "" }; + if (args.includes("--unset-all")) header = ""; + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "ls-remote") { + if (args.includes("--refs")) { + expect(header).toBe(targetHeader); + expect(args).toEqual(["ls-remote", "--refs", "--exit-code", "origin", "refs/heads/main"]); + return { exitCode: 0, stdout: `${"a".repeat(40)}\trefs/heads/main\n`, stderr: "" }; + } + return { exitCode: 0, stdout: `${prerequisite}\trefs/heads/feature-branch\n`, stderr: "" }; + } + if (args[0] === "bundle") { + expect(options.env.GIT_ALLOW_PROTOCOL).toBe(""); + expect(header).toBe(targetHeader); + return args[1] === "verify" ? { exitCode: 1, stdout: "", stderr: `The bundle requires this ref:\n${prerequisite}\n` } : { exitCode: 0, stdout: `${candidate} HEAD\n`, stderr: "" }; + } + if (args[0] === "cat-file") return { exitCode: recovered ? 0 : 1, stdout: "", stderr: "" }; + if (args[0] === "rev-parse") return { exitCode: 0, stdout: args[1] === "--is-shallow-repository" ? "true\n" : `${prerequisite}\n`, stderr: "" }; + if (args[0] === "fetch") { + expect(args.includes(remote)).toBe(true); + expect(header).toBe(headHeader); + expect(options.silent).toBe(true); + if (failRecovery) return { exitCode: 128, stdout: "", stderr: `synthetic private remote diagnostic ${headToken}` }; + if (args[1] !== "--unshallow") recovered = true; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + try { + const module = await loadModule(); + const handler = await module.main({ + signed_commits: false, + allow_full_history_fetch: true, + "head-repo": headRepo, + "head-github-token": headToken, + allowed_repos: ["test-owner/test-repo", headRepo], + }); + const result = await handler({ branch: "feature-branch" }, {}); + expect(result.success).toBe(!failRecovery); + expect(recovered).toBe(!failRecovery); + expect(header).toBe(targetHeader); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", "--unshallow", remote], expect.any(Object)); + if (failRecovery) { + expect(result.error).toContain("Explicit bundle history recovery failed"); + expect(result.error).not.toContain("synthetic"); + expect(mockExec.getExecOutput.mock.calls.some(([, args]) => args[0] === "bundle" && args[1] === "unbundle")).toBe(false); + } else { + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["fetch", remote, prerequisite], expect.any(Object)); + expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["bundle", "unbundle", bundleSnapshot(bundlePath)], expect.objectContaining({ env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "", GIT_NO_LAZY_FETCH: "1" }) })); + } + const logs = JSON.stringify([mockCore.info.mock.calls, mockCore.warning.mock.calls, mockCore.error.mock.calls, mockCore.debug.mock.calls]); + expect(logs).not.toContain(headToken); + expect(logs).not.toContain(headHeader); + expect(logs).not.toContain(targetHeader); + expect(logs).not.toContain("synthetic private remote diagnostic"); } finally { pushSignedSpy.mockRestore(); } @@ -3016,7 +3102,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -3075,7 +3161,7 @@ index 0000000..abc1234 } // No non-merge commits exist → return empty string if (cmd === "git" && args[0] === "log" && args.includes("--no-merges")) { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } // Fall back: last commit message is the merge commit's message if (cmd === "git" && args[0] === "log" && args[1] === "-1" && args[2] === "--format=%B") { @@ -3088,7 +3174,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -3130,7 +3216,7 @@ index 0000000..abc1234 if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); @@ -3181,12 +3267,12 @@ index 0000000..abc1234 } // No staged changes after soft reset → triggers validation error if (cmd === "git" && args[0] === "diff" && args[1] === "--cached" && args[2] === "--name-only") { - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); } if (cmd === "git" && args[0] === "rev-list") { return Promise.resolve({ exitCode: 0, stdout: "2\n", stderr: "" }); } - return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + return Promise.resolve({ exitCode: 0, stdout: args?.[0] === "bundle" && args[1] === "unbundle" ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\n" : "", stderr: "" }); }); const module = await loadModule(); diff --git a/actions/setup/js/transport_integrity_git.test.cjs b/actions/setup/js/transport_integrity_git.test.cjs index e8c8c253646..f3505323f17 100644 --- a/actions/setup/js/transport_integrity_git.test.cjs +++ b/actions/setup/js/transport_integrity_git.test.cjs @@ -65,12 +65,12 @@ describe("transport integrity Git cleanup", () => { fs.rmSync(directory, { recursive: true, force: true }); }); - it("deletes imported temporary bundle refs after an integrity failure without publishing", async () => { + it("cleans up after a bundle integrity failure without publishing", async () => { mustGit(["branch", "feature"]); const bundle = path.join(directory, "aw-feature.bundle"); mustGit(["bundle", "create", bundle, "feature"]); const api = execApi(args => { - if (args[0] === "fetch" && args[1].endsWith(".bundle")) fs.writeFileSync(bundle, "changed after import"); + if (args[0] === "bundle" && args[1] === "unbundle") fs.writeFileSync(bundle, "changed after import"); }); const { applyBundleToBranch } = require("./create_pull_request.cjs"); const handler = withTransportIntegrity(async (_message, _ids, transport) => { @@ -81,7 +81,6 @@ describe("transport integrity Git cleanup", () => { await expect(handler({}, {})).resolves.toMatchObject({ success: false, error_type: "ERR_TRANSPORT_INTEGRITY" }); expect(mustGit(["for-each-ref", "--format=%(refname)", "refs/bundles/"]).trim()).toBe(""); expect(api.exec.mock.calls.some(([, args]) => args[0] === "push")).toBe(false); - expect(api.exec).toHaveBeenCalledWith("git", ["update-ref", "-d", expect.stringMatching(/^refs\/bundles\/create-pr-result-/)]); }); it.each([false, true])("aborts a real patch conflict and restores the checkout (transport changed: %s)", async tamper => { diff --git a/actions/setup/js/transport_integrity_handlers.test.cjs b/actions/setup/js/transport_integrity_handlers.test.cjs index 91e0ddd3f37..6591aa923e3 100644 --- a/actions/setup/js/transport_integrity_handlers.test.cjs +++ b/actions/setup/js/transport_integrity_handlers.test.cjs @@ -75,6 +75,7 @@ describe.each(["create_pull_request", "push_to_pull_request_branch"])("%s transp getExecOutput: vi.fn(async (_command, args) => { if (args[0] === "rev-parse" && args[1] === "HEAD") return { exitCode: 0, stdout: "b".repeat(40), stderr: "" }; if (args[0] === "diff" && args.includes("--name-only")) return { exitCode: 0, stdout: "file.txt\0", stderr: "" }; + if (args[0] === "bundle" && args[1] === "unbundle") return { exitCode: 0, stdout: `${"b".repeat(40)} refs/heads/feature\n`, stderr: "" }; return { exitCode: 0, stdout: "", stderr: "" }; }), }; @@ -269,38 +270,50 @@ describe.each(["create_pull_request", "push_to_pull_request_branch"])("%s transp assertNoRemoteSideEffects(); }); - it.each(["initial import", "head discovery", "prerequisite recovery"])("freezes bundle import and refuses tampering during %s", async stage => { + it.each(["prerequisite verification", "bundle import", "prerequisite recovery"])("freezes bundle input and refuses tampering during %s", async stage => { fs.writeFileSync(bundle, "bundle original"); const originalOutput = global.exec.getExecOutput.getMockImplementation(); + const prerequisite = "d".repeat(40); let imports = 0; let attacked = false; + let recovered = false; const tamper = () => { attacked = true; fs.writeFileSync(bundle, "bundle tampered"); }; global.exec.exec.mockImplementation(async (_command, args) => { - if (stage === "prerequisite recovery" && args[0] === "fetch" && args.includes("d".repeat(40))) tamper(); return 0; }); global.exec.getExecOutput.mockImplementation(async (command, args) => { - if (args[0] === "fetch" && args.some(arg => arg.endsWith(".bundle"))) { + if (args[0] === "bundle" && args[1] === "verify") { + if (stage === "prerequisite verification") tamper(); + return { + exitCode: 0, + stdout: stage === "prerequisite recovery" ? `The bundle requires this ref:\n ${prerequisite}\n` : "", + stderr: "", + }; + } + if (args[0] === "cat-file" && args[1] === "-e" && args[2] === `${prerequisite}^{commit}`) { + return { exitCode: recovered ? 0 : 1, stdout: "", stderr: "" }; + } + if (args[0] === "fetch" && args.includes(prerequisite)) { + tamper(); + recovered = true; + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "bundle" && args[1] === "unbundle") { imports++; const snapshot = args.find(arg => arg.endsWith(".bundle")); snapshots.add(snapshot); expect(fs.readFileSync(snapshot, "utf8")).toBe("bundle original"); - if (stage === "initial import") tamper(); - const stderr = stage === "head discovery" ? "couldn't find remote ref" : `Repository lacks these prerequisite commits:\n${"d".repeat(40)}\n`; - return { exitCode: 1, stdout: "", stderr }; - } - if (stage === "head discovery" && args[0] === "bundle" && args[1] === "list-heads") { - tamper(); - return { exitCode: 0, stdout: `${"e".repeat(40)} HEAD\n`, stderr: "" }; + if (stage === "bundle import") tamper(); + return { exitCode: 0, stdout: `${"b".repeat(40)} refs/heads/feature\n`, stderr: "" }; } return originalOutput(command, args); }); - const result = await run(); + const result = await run(stage === "prerequisite recovery" ? { allow_full_history_fetch: true } : {}); expect(attacked).toBe(true); - expect(imports).toBe(1); + expect(imports).toBe(stage === "bundle import" ? 1 : 0); expect(result.success).toBe(false); expect(result.error).toContain("SECURITY:"); assertNoRemoteSideEffects(); diff --git a/actions/setup/js/types/handler-factory.d.ts b/actions/setup/js/types/handler-factory.d.ts index 07703461d6e..00de0247346 100644 --- a/actions/setup/js/types/handler-factory.d.ts +++ b/actions/setup/js/types/handler-factory.d.ts @@ -28,6 +28,8 @@ interface HandlerConfig { fallback_as_pull_request?: boolean; /** When false, skip GraphQL signed commits and push the local git history directly. */ signed_commits?: boolean; + /** Explicit opt-in to potentially unbounded bundle prerequisite downloads. Defaults to false. */ + allow_full_history_fetch?: boolean; /** Additional handler-specific configuration properties */ [key: string]: any; } diff --git a/docs/adr/68806-explicit-offline-bundle-history-recovery.md b/docs/adr/68806-explicit-offline-bundle-history-recovery.md new file mode 100644 index 00000000000..3fede0b47ec --- /dev/null +++ b/docs/adr/68806-explicit-offline-bundle-history-recovery.md @@ -0,0 +1,58 @@ +# ADR-68806: Make PR bundle history recovery explicit and validate bundles offline + +**Date**: 2026-10-14 +**Status**: Proposed +**Deciders**: PR #68806 maintainers (author: @pelikhan) +**Reviewers**: Pending maintainer review + +--- + +### Context + +Safe-output pull-request handlers (`create_pull_request` and `push_to_pull_request_branch`) import agent-produced Git bundles and validate their prerequisites, history, and changed content before publishing. [PR #68717](https://github.com/github/gh-aw/pull/68717), merged as [96b2d13](https://github.com/github/gh-aw/commit/96b2d13e5d3abe1240deed2a93f7b26db7d2a8e0), changed the push handler to fetch the live base-branch tip from the target checkout's origin so validation could distinguish trusted base changes from agent changes; automatic bundle-prerequisite recovery already existed. [PR #68795](https://github.com/github/gh-aw/pull/68795), commit [392e085](https://github.com/github/gh-aw/commit/392e085cc15345c181314bb6841184fc99ce34d4), reverted that change because fetching the live tip in massive shallow monorepos could download unrestricted reachable history. Partial clones also risk implicit downloads during local object inspection, while fork PRs may require prerequisites available only from the head repository under different credentials. The design must preserve validation without implicit object downloads, distinguish target-base provenance from head-history recovery, and make any potentially unbounded recovery a deliberate choice. + +### Decision + +History recovery will require `allow-full-history-fetch: true` on the specific `create-pull-request` or `push-to-pull-request-branch` handler; this default-false option accepts only literal booleans, not strings or expressions, and is not a global `safe-outputs` setting. The opt-in explicitly permits potentially unbounded prerequisite fetch and unshallow downloads, so authenticated provisioning of the necessary objects before validation is preferred. + +Trusted base-tip discovery will always use a validated, exact `refs/heads/` ref and metadata-only `git ls-remote --refs --exit-code` against the trusted target origin with its own credentials, pinning the validated returned SHA; neither setting of the opt-in permits an object fetch solely to establish this optional baseline. Explicit prerequisite recovery for a fork PR will instead use the validated PR head remote and its head credentials, keeping recovery authentication separate from target-origin baseline authentication; head metadata, bundle metadata, and workflow-start SHAs do not independently establish a trusted base. + +Bundle import via `git bundle unbundle`, prerequisite checks, optional baseline ancestry/tree inspection, and the push handler's bundle diff/history checks will use `GIT_ALLOW_PROTOCOL=""` together with `GIT_NO_LAZY_FETCH=1`. Git 2.45.1 introduced `GIT_NO_LAZY_FETCH` support; older versions may not honor it, so protocol denial remains the compatibility safeguard against implicit transport. If trusted metadata or local ancestry cannot establish the optional baseline, the push handler will conservatively retain PR-head validation without excluding unverified base history; missing objects required for validation will fail rather than bypass checks. The primary drivers are credential isolation and avoiding implicit download costs, not a byte limit or a fully network-free handler. + +See the versioned [Git 2.45.1 `git-upload-pack` documentation](https://git-scm.com/docs/git-upload-pack/2.45.1) for the `GIT_NO_LAZY_FETCH` environment variable. + +### Alternatives Considered + +#### Alternative 1: Keep automatic recovery and live base-tip fetching with depth limits, blob filters, or exact SHAs + +Retain the convenience of automatic prerequisite recovery and restore the live-tip fetch from #68717, reducing transfer with `--depth`, `--filter=blob:none`, or exact-SHA requests. These controls can reduce some fetches, but none is a byte bound: a wide commit can contain many objects, filtered clones may later download missing content, and fetching an exact commit can include its reachable history. This alternative does not remove the implicit-download risk that motivated #68795. + +#### Alternative 2: A single global `safe-outputs.allow-full-history-fetch` flag + +One top-level switch would be simpler to document and set once per workflow. It would, however, enable recovery for both publishing handlers even when only one needs it. Per-handler settings make the download authorization explicit at each consumer, at the cost of repeated configuration when both handlers require recovery. + +#### Alternative 3: Always require a deep (non-shallow) checkout for bundle-publishing workflows + +Requiring `fetch-depth: 0` for every publishing workflow would simplify history handling, but unnecessarily downloads history when a shallow checkout and its bundle already contain the required objects. Full history alone also does not guarantee that partial-clone blobs or fork-only prerequisites are present. Authenticated, repository-aware provisioning remains the preferred operational approach; the opt-in is retained for workflows that need handler-side recovery rather than imposing a deep checkout on all workflows. + +### Consequences + +#### Positive +- Bundle import and local validation do not fetch objects implicitly, including from partial-clone promisor remotes. +- Metadata-only base discovery preserves trusted target-origin provenance without downloading optional baseline objects or borrowing fork-head credentials. +- Per-handler authorization makes potentially unbounded recovery explicit; diagnostics can direct maintainers to authenticated provisioning or the opt-in without exposing raw remote errors. + +#### Negative +- Shallow or partial checkouts can fail when required commits, trees, or blobs are missing, replacing automatic recovery with an explicit provisioning decision. +- Conservative PR-head fallback can count legitimate base-branch changes as agent changes and reject a valid base merge; it intentionally does not relax protected-file or history checks. +- Opted-in recovery has no promised byte bound and can still be expensive or fail when the selected remote or credentials cannot supply the prerequisites. Fork-aware remote selection and credential separation add implementation and regression-test complexity. +- Both handlers need their own opt-in, and future local Git inspection calls must preserve the offline environment to avoid reintroducing implicit downloads. + +#### Neutral +- A shallow checkout is not itself an error if required objects and checks are available locally; offline validation does not deepen or unshallow it. +- Metadata discovery, ordinary PR-head fetch/push, and existing signed-push recovery are separate from offline bundle validation. This decision does not claim that the complete handler performs no network I/O or that the option bounds all handler transfers. +- The option must remain consistent across the JS handlers, Go configuration/compiler/registry, workflow schema, tests, and reference documentation. + +--- + +*This proposal records the reviewed implementation contract for PR #68806. Maintainer acceptance remains pending.* diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index e64e63cf1d2..87428013198 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -144,10 +144,10 @@ By default a random hex suffix is appended to the agent-provided branch name to The agent's commits are packaged as a **git bundle** and uploaded as an Actions artifact. A separate, permission-controlled `safe_outputs` job then: 1. Checks out the target repository at the base branch (shallow, depth 1). Any additional `fetch:` refs declared in `checkout:` frontmatter for the target repository are fetched so their commits are locally available. -2. Applies the bundle via `git fetch `. If prerequisite commits are missing (because the base branch advanced while the agent was running), they are fetched from origin by SHA and the bundle fetch retried automatically. +2. Imports the local bundle offline with `git bundle unbundle`. If prerequisite commits are missing, the handler fails by default; provision the required objects during authenticated checkout or opt in to prerequisite recovery with `allow-full-history-fetch: true` on that specific PR handler. The opt-in permits potentially unbounded recovery downloads, but bundle import and validation remain offline. 3. Pushes the branch using the GitHub GraphQL API (signed commits) and creates the pull request. -If the base branch advances between agent start and `safe_outputs` apply, the PR is created slightly behind the current base — normal behavior the author can address with a rebase. If a non-fast-forward race occurs during the push itself, the job creates a fallback PR from a temporary branch so no changes are lost. +If the base branch advances between agent start and `safe_outputs` apply, the PR can be created slightly behind the current base and addressed with a rebase when the necessary bundle objects are available. Otherwise, missing prerequisites cause the handler to fail unless recovery is explicitly enabled as described above. If a non-fast-forward race occurs during the push itself, the job creates a fallback PR from a temporary branch so no changes are lost. An older **patch transport** (`git format-patch` / `git am --3way`) is used when bundle data is unavailable. `--3way` resolves cleanly against an updated base when there are no conflicts; if it cannot, the patch is applied at the agent's original base commit and the PR UI shows the conflicts for manual resolution. @@ -435,7 +435,25 @@ branch name (see [issue #37835](https://github.com/github/gh-aw/issues/37835)). By default, pushes are replayed through GitHub's signed commit API because `signed-commits: true` means signed commits are required. Set `signed-commits: false` only for repositories that do not require signed commits; this uses direct `git push` and can preserve merge commits that the signed commit API cannot represent. This field is supported by both `create-pull-request` and `push-to-pull-request-branch`. -For unsigned bundle pushes that newly incorporate the PR base commit fetched by the handler, patch-size, changed-content-size, and protected-file checks compare against a validation-only merge of the old PR head and that trusted base. Unmodified base-branch changes are excluded; agent changes and conflict resolutions remain subject to the checks. Agent-created octopus merges (three or more parents) are rejected in this mode because their resolution changes cannot be validated with Git's remerge diffs. If the trusted baseline cannot be established, checks use the old PR head. Staged mode retains offline patch-preview checks. The push remains append-only: merge an already-fetched base ref rather than rebasing existing PR commits. +For unsigned bundle pushes, the handler pins the current PR base branch tip using an exact-ref, metadata-only `git ls-remote` against the target checkout's origin, with that checkout's credentials rather than a fork-head token. It does not fetch objects to establish this optional baseline. After local bundle import, patch-size, changed-content-size, and protected-file checks can compare against a validation-only merge of the old PR head and that trusted tip when the necessary objects and ancestry are locally available. Unmodified base-branch changes are excluded; agent changes and conflict resolutions remain subject to the checks. Agent-created octopus merges (three or more parents) are rejected because their resolution changes cannot be validated with Git's remerge diffs. + +Local bundle import and optional ancestry/tree inspection deny all Git transports, including promisor lazy downloads. This uses `GIT_ALLOW_PROTOCOL` as well as `GIT_NO_LAZY_FETCH`, so it does not depend on newer Git versions recognizing the latter. Missing credentials, unavailable history or blobs, and unsupported `merge-tree` retain conservative PR-head validation; they never cause an optional baseline fetch. A newer base tip not merged into the candidate also retains PR-head validation; workflow-start SHAs and agent metadata are not alternate provenance sources. Staged mode retains offline patch-preview checks. The push remains append-only: merge an already-provisioned base ref rather than rebasing existing PR commits. + +### Bundle prerequisites and history downloads + +`create-pull-request` and `push-to-pull-request-branch` import bundles without automatic prerequisite downloads by default. A missing prerequisite fails with guidance to provision the required objects in the target checkout, or explicitly enable recovery: + +```yaml wrap +safe-outputs: + push-to-pull-request-branch: + allow-full-history-fetch: true +``` + +**This opt-in permits unbounded downloads**, including `--unshallow` and fetch-by-SHA. Commit depth, an exact SHA, and `blob:none` are not byte limits. Prefer configuring the required refs and history during authenticated [`checkout:`](/gh-aw/reference/checkout/) instead, using credentials for each target repository. Choose checkout depth deliberately for the repository; full history can be prohibitively expensive in a large monorepo. A bundle whose prerequisites and required content are already present needs no recovery, including in shallow checkouts. Partial clones may fail locally when required content is absent, even if the commit object exists. + +`allow-full-history-fetch` is configured independently under `safe-outputs.create-pull-request` or `safe-outputs.push-to-pull-request-branch`. Both default to `false` and accept only a literal boolean. The setting affects that handler's bundle prerequisite recovery, not optional baseline discovery, ordinary PR-head fetch/push operations, or other network paths. Recovery authentication failures use fixed diagnostics without printing credentials or raw remote errors. + +For `push-to-pull-request-branch`, opted-in recovery uses the validated PR head repository and the same credentials as PR-head fetch/push operations, including a configured automation fork (`head-repo` and `head-github-token`). Fork credentials are scoped to recovery and restored before local bundle import and validation. Trusted base discovery still queries only target `origin` metadata with target-checkout credentials; it never fetches base objects. `create-pull-request` recovery continues to use its checkout's `origin`. ### Cross-repo usage diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 6708e40f88d..7c07a410801 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -2151,6 +2151,10 @@ safe-outputs: create-pull-request: ``` +### Bundle History Recovery (`allow-full-history-fetch:`) + +Configured independently under `create-pull-request` and `push-to-pull-request-branch`, defaulting to `false`. Missing local bundle prerequisites fail with checkout provisioning guidance instead of downloading history. Setting this literal boolean to `true` explicitly permits potentially unbounded history downloads for that handler. It does not enable object downloads for optional validation baselines. See [bundle prerequisites and history downloads](/gh-aw/reference/safe-outputs-pull-requests/#bundle-prerequisites-and-history-downloads). + ### Custom Runner Image Specify a custom runner for safe output jobs (default: `ubuntu-slim`): diff --git a/pkg/parser/schema_safe_outputs_target_test.go b/pkg/parser/schema_safe_outputs_target_test.go index 476d3f126ae..98cb51eb8d4 100644 --- a/pkg/parser/schema_safe_outputs_target_test.go +++ b/pkg/parser/schema_safe_outputs_target_test.go @@ -6,6 +6,51 @@ import ( "testing" ) +func TestMainWorkflowSchema_AllowFullHistoryFetch(t *testing.T) { + t.Parallel() + for _, handler := range []string{"create-pull-request", "push-to-pull-request-branch"} { + for _, tc := range []struct { + name string + value any + valid bool + }{ + {"enabled", true, true}, + {"disabled", false, true}, + {"string", "true", false}, + {"expression", "${{ inputs.allow-history }}", false}, + } { + t.Run(handler+"/"+tc.name, func(t *testing.T) { + t.Parallel() + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "safe-outputs": map[string]any{ + handler: map[string]any{"allow-full-history-fetch": tc.value}, + }, + } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/history-fetch-test.md") + if (err == nil) != tc.valid { + t.Fatalf("schema validity = %v, want %v: %v", err == nil, tc.valid, err) + } + }) + } + } + t.Run("reject-global-option", func(t *testing.T) { + t.Parallel() + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "safe-outputs": map[string]any{ + "create-pull-request": map[string]any{}, + "allow-full-history-fetch": true, + }, + } + if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/history-fetch-test.md"); err == nil { + t.Fatal("expected global history-fetch option to fail schema validation") + } + }) +} + func TestMainWorkflowSchema_SafeOutputConfigCoverage(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7041b36b0a9..62fd4194faa 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -8947,6 +8947,11 @@ } ] }, + "allow-full-history-fetch": { + "type": "boolean", + "description": "Allow potentially unbounded history downloads to recover missing local git bundle prerequisites. Disabled by default. Prefer provisioning objects during authenticated checkout.", + "default": false + }, "allow-workflows": { "type": "boolean", "description": "When true, adds workflows: write to the GitHub App token permissions. Required when allowed-files targets .github/workflows/ paths. Requires safe-outputs.github-app to be configured because the workflows permission is a GitHub App-only permission and cannot be granted via GITHUB_TOKEN.", @@ -11219,6 +11224,11 @@ ], "description": "Transport format for packaging changes. \"bundle\" (default) uses git bundle. \"am\" uses git format-patch/git am. Accepts a GitHub Actions expression for reusable workflows." }, + "allow-full-history-fetch": { + "type": "boolean", + "description": "Allow potentially unbounded history downloads to recover missing local git bundle prerequisites. Disabled by default. Does not enable object downloads for optional validation baselines.", + "default": false + }, "allow-workflows": { "type": "boolean", "description": "When true, adds workflows: write to the GitHub App token permissions. Required when allowed-files targets .github/workflows/ paths. Requires safe-outputs.github-app to be configured because the workflows permission is a GitHub App-only permission and cannot be granted via GITHUB_TOKEN.", diff --git a/pkg/workflow/compiler_safe_outputs_config_targets_test.go b/pkg/workflow/compiler_safe_outputs_config_targets_test.go index 4db1345a9a6..95b83e925b0 100644 --- a/pkg/workflow/compiler_safe_outputs_config_targets_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_targets_test.go @@ -16,6 +16,64 @@ import ( // Target Repo and Patch Limit Tests // ======================================== +func TestAllowFullHistoryFetch(t *testing.T) { + t.Parallel() + for _, value := range []any{nil, false, true} { + name := "default" + switch value { + case false: + name = "disabled" + case true: + name = "enabled" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + compiler := NewCompiler() + raw := map[string]any{ + "create-pull-request": map[string]any{}, + "push-to-pull-request-branch": map[string]any{}, + } + if value != nil { + raw["create-pull-request"] = map[string]any{"allow-full-history-fetch": value} + raw["push-to-pull-request-branch"] = map[string]any{"allow-full-history-fetch": value} + } + cfg := compiler.extractSafeOutputsConfig(map[string]any{"safe-outputs": raw}) + require.NotNil(t, cfg) + for _, handler := range []string{"create_pull_request", "push_to_pull_request_branch"} { + got := handlerRegistry[handler](cfg) + require.NotNil(t, got) + if value != true { + assert.NotContains(t, got, "allow_full_history_fetch") + } else { + assert.Equal(t, value, got["allow_full_history_fetch"]) + } + } + encoded, err := generateSafeOutputsConfig(&WorkflowData{SafeOutputs: cfg}) + require.NoError(t, err) + var runtime map[string]map[string]any + require.NoError(t, json.Unmarshal([]byte(encoded), &runtime)) + for _, handler := range []string{"create_pull_request", "push_to_pull_request_branch"} { + if value != true { + assert.NotContains(t, runtime[handler], "allow_full_history_fetch") + } else { + assert.Equal(t, value, runtime[handler]["allow_full_history_fetch"]) + } + } + }) + } +} + +func TestAllowFullHistoryFetchIsHandlerScoped(t *testing.T) { + t.Parallel() + cfg := NewCompiler().extractSafeOutputsConfig(map[string]any{"safe-outputs": map[string]any{ + "create-pull-request": map[string]any{"allow-full-history-fetch": true}, + "push-to-pull-request-branch": map[string]any{}, + }}) + require.NotNil(t, cfg) + assert.Equal(t, true, handlerRegistry["create_pull_request"](cfg)["allow_full_history_fetch"]) + assert.NotContains(t, handlerRegistry["push_to_pull_request_branch"](cfg), "allow_full_history_fetch") +} + // TestEmptySafeOutputsConfig tests behavior with no safe outputs func TestEmptySafeOutputsConfig(t *testing.T) { compiler := NewCompiler() diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index f0fb6ec221b..0beee6ffbe0 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -117,6 +117,7 @@ type CreatePullRequestsConfig struct { RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. + AllowFullHistoryFetch bool `yaml:"allow-full-history-fetch,omitempty"` // Explicit opt-in to unbounded bundle prerequisite downloads (default: false). AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. CloseOlderConfig `yaml:",inline"` // Shared close-older settings; Enabled is sourced from close-older-pull-requests. } diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index b7c2ad4cfcc..a9d1da2e305 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -35,6 +35,7 @@ type PushToPullRequestBranchConfig struct { PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). FallbackAsPullRequest *bool `yaml:"fallback-as-pull-request,omitempty"` // When true (default), creates a fallback pull request if direct push fails due to diverged/non-fast-forward branch. When false, fallback is disabled and pull-requests: write is not requested. SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. + AllowFullHistoryFetch bool `yaml:"allow-full-history-fetch,omitempty"` // Explicit opt-in to unbounded bundle prerequisite downloads (default: false). AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. CheckBranchProtection *bool `yaml:"check-branch-protection,omitempty"` // When false, skips the branch protection API pre-flight check. Default is true (check enabled). Set to false to avoid needing administration: read permission. } @@ -226,6 +227,10 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) } } + if allow, ok := configMap["allow-full-history-fetch"].(bool); ok { + pushToBranchConfig.AllowFullHistoryFetch = allow + } + // Parse allow-workflows: when true, adds workflows: write to the GitHub App token if allowWorkflows, exists := configMap["allow-workflows"]; exists { if allowWorkflowsBool, ok := allowWorkflows.(bool); ok { diff --git a/pkg/workflow/safe_outputs_handler_registry_pull_requests.go b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go index 5111c71e9a9..f88ab41e766 100644 --- a/pkg/workflow/safe_outputs_handler_registry_pull_requests.go +++ b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go @@ -25,6 +25,7 @@ var pullRequestHandlerRegistry = map[string]handlerBuilder{ AddIfTrue("ignore_missing_branch_failure", c.IgnoreMissingBranchFailure). AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix). AddDefault("max_patch_size", maxPatchSize). + AddIfTrue("allow_full_history_fetch", c.AllowFullHistoryFetch). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddIfNotEmpty("head-repo", c.HeadRepoSlug). AddIfNotEmpty("base_branch", c.BaseBranch). @@ -228,6 +229,7 @@ func buildCreatePullRequestHandlerConfig(cfg *SafeOutputsConfig) map[string]any } c := cfg.CreatePullRequests builder := newCreatePullRequestHandlerConfigBuilder(cfg, c) + builder.AddIfTrue("allow_full_history_fetch", c.AllowFullHistoryFetch) // Stacked pull requests are enabled by default; only emit the flag when disabled // (e.g. GitHub Enterprise Server instances without stacked pull request support). if !isStackedPullRequestsEnabled(c) {