Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions .github/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,8 @@
"github-agentic-workflows": {
"type": "local",
"command": "gh",
"args": [
"aw",
"mcp-server"
],
"tools": [
"compile",
"audit",
"logs",
"inspect",
"status",
"audit-diff"
]
"args": ["aw", "mcp-server"],
"tools": ["compile", "audit", "logs", "inspect", "status", "audit-diff"]
}
}
}
}
51 changes: 47 additions & 4 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,23 @@ function parsePositiveInteger(value) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}

/**
* Uses git as the source of truth for the files modified by a fetched bundle ref.
*
* @param {{ getExecOutput: (command: string, args?: string[], options?: any) => Promise<{ stdout: string }> }} exec
* @param {Record<string, unknown>} gitOptions
* @param {string} rangeBaseRef
* @param {string} bundleRef
* @returns {Promise<string[]>}
*/
async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) {
const bundleDiffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`], gitOptions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] getBundlePreApplyFiles calls getExecOutput without ignoreReturnCode: true, so any git failure (e.g. a ref that unexpectedly does not exist) will throw an unhandled exception rather than produce a clear diagnostic message.

💡 Suggestion

Consider wrapping the call with ignoreReturnCode: true and surfacing git error output explicitly:

async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) {
  const bundleDiffResult = await exec.getExecOutput(
    "git",
    ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`],
    { ...gitOptions, ignoreReturnCode: true }
  );
  if (bundleDiffResult.exitCode !== 0) {
    throw new Error(`git diff for bundle pre-check failed (exit ${bundleDiffResult.exitCode}): ${bundleDiffResult.stderr}`);
  }
  return bundleDiffResult.stdout.split("\n").map(f => f.trim()).filter(Boolean);
}

This is especially valuable because refs should always be present at this point in the flow, so a failure here indicates an unexpected state worth flagging loudly.

return bundleDiffResult.stdout
.split("\n")
.map(f => f.trim())
.filter(Boolean);
}

/**
* Main handler factory for push_to_pull_request_branch
* Returns a message handler function that processes individual push_to_pull_request_branch messages
Expand Down Expand Up @@ -208,9 +225,9 @@ async function main(config = {}) {
core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`);
}

// Always require a patch file for policy enforcement. Bundle is used for apply-time
// transport, but allowed-files/protected-files checks must run on patch content
// (see validation block below that calls checkFileProtection on patchContent).
// Always require a patch file. The patch remains the preview/debug artifact and
// the first-pass validation input; bundle transport adds an authoritative
// pre-apply git diff check later after the bundle ref has been fetched.
if (!hasPatchFile) {
const msg = "No patch file found - cannot push without changes";

Expand Down Expand Up @@ -789,6 +806,32 @@ async function main(config = {}) {
}
core.info(`Fetched bundle to ${bundleRef}`);

// SECURITY: Use git's own diff against the fetched bundle ref as the
// authoritative pre-apply file set for bundle transport. This keeps
// bundle pre-check and post-apply verification aligned even when the
// patch artifact under-detects files (for example, merge-resolution
// content preserved only by the bundle transport).
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot refactor to own function, add git integration tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6cc5cdc: extracted the bundle pre-apply git diff into getBundlePreApplyFiles() and added git-backed integration coverage for fetched bundle refs, including merge-history bundles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6cc5cdc: getBundlePreApplyFiles() is extracted as its own function (line 114) and called at the bundle pre-check site (line 815). Integration tests covering fetched bundle refs, including merge-history bundles, are in push_to_pull_request_branch.integration.test.cjs.

const bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled git error from getBundlePreApplyFiles collapses into generic bundle failure: getExecOutput throws on non-zero exit and there is no ignoreReturnCode: true or try/catch here — any git diff failure propagates into the outer catch (bundleError) which logs only "Failed to apply bundle", discarding the real cause.

💡 Why this matters / suggested fix

Before this PR, bundle transport never invoked git diff. Now it does, introducing a new failure mode: if rangeBaseRef is origin/${branchName} (the fallback when remoteHeadBeforePatch was not captured) and the remote ref is not locally available, git diff exits non-zero, the outer catch fires, and a valid bundle push fails with an unhelpful error.

Two options:

Option A — pass ignoreReturnCode: true and handle empty/error in the helper:

async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) {
  const result = await exec.getExecOutput(
    "git", ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`],
    { ...gitOptions, ignoreReturnCode: true }
  );
  if (result.exitCode !== 0) {
    core.warning(`Bundle pre-apply diff failed (rangeBaseRef=${rangeBaseRef}): ${result.stderr}`);
    return [];
  }
  return result.stdout.split("\n").map(f => f.trim()).filter(Boolean);
}

Option B — wrap the call in its own try/catch at the call site and log before falling through.

Either way, the error must be surfaced as a named warning, not swallowed by the bundle transport catch.

if (bundleFiles.length > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent policy bypass when bundle diff returns empty: if git diff resolves to zero paths — a stale rangeBaseRef (origin/${branchName} not tracked locally), objects missing in a shallow clone, or a bundle whose tip matches the base — the guard skips the entire authoritative check and the bundle applies without any file-scope validation.

💡 Impact and suggested fix

Before this PR, bundle transport never called git diff, so a new failure path now exists: in environments where rangeBaseRef is origin/${branchName} and the remote ref is not locally cached, the diff may return empty and silently bypass the check — while the first-pass patch check already passed (it only saw .changeset). This creates a hole precisely in the scenario this PR is trying to close.

The guard should fail closed, not open. Minimum fix: emit an observable warning and document the skip rather than treating an empty diff as safe:

const bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef);
if (bundleFiles.length === 0) {
  core.warning(`Pre-apply bundle verification: git diff returned no paths (rangeBaseRef=${rangeBaseRef}); check skipped`);
} else {
  core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`);
  const bundleProtection = checkFileProtectionPostApply(bundleFiles, config);
  // deny/fallback handling unchanged
}

Ideally also add a test asserting the empty-diff case logs a warning and does not silently succeed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] When getBundlePreApplyFiles returns an empty array, the pre-apply protection block is silently skipped with no log output. An empty bundle diff is almost certainly unexpected and warrants at least a core.debug message so operators can distinguish "protection ran and allowed" from "protection was not entered at all".

💡 Suggestion
const bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef);
if (bundleFiles.length === 0) {
  core.debug("Pre-apply bundle verification: git diff returned no files; skipping protection check");
} else {
  core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`);
  // ... existing protection logic
}

This also makes it easier to diagnose future false-negatives where the diff range is wrong and git silently returns nothing.

core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`);
const bundleProtection = checkFileProtectionPostApply(bundleFiles, config);
if (bundleProtection.action === "deny") {
const filesStr = bundleProtection.files.join(", ");
const msg =
bundleProtection.source === "post-apply"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnose] The bundleProtection.source === "post-apply" ternary is correct in behavior but the naming may confuse future maintainers: this is a pre-apply check that calls checkFileProtectionPostApply. The source value "post-apply" comes from checkFileProtectionPostApply's allowlist branch (line 286 of manifest_file_helpers.cjs), not from the timing of this call. A brief comment clarifying this would prevent confusion.

💡 Suggestion
// bundleProtection.source reflects which check fired:
// "post-apply"  → file not in allowed_files list
// "protected"   → file matched a protected-files rule
const msg =
  bundleProtection.source === "post-apply"
    ? `Cannot push...`
    : `Cannot push (protected files)...`;

? `Cannot push to pull request branch: bundle modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the bundle.`
: `Cannot push to pull request branch: bundle modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`;
core.error(msg);
return { success: false, error: msg };
}
if (bundleProtection.action === "fallback") {
core.warning(`Protected file protection triggered (fallback-to-issue): ${bundleProtection.files.join(", ")}. Will create review issue instead of pushing.`);
return await createProtectedFilesFallbackIssue(bundleProtection.files);
}
}
}

// Point the checked-out branch at the bundle tip directly. In shallow
// checkouts, merge --ff-only can fail to discover the ancestry even
// when the bundle tip is based on the current branch tip and the
Expand Down Expand Up @@ -1300,4 +1343,4 @@ async function main(config = {}) {
};
}

module.exports = { main, HANDLER_TYPE };
module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles };
142 changes: 142 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.integration.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";
import { spawnSync } from "child_process";

const require = createRequire(import.meta.url);
const { getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs");

global.core = {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
};

function execGit(args, options = {}) {
const result = spawnSync("git", args, {
encoding: "utf8",
...options,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0 && !options.allowFailure) {
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
}
return result;
}

function createRepo(prefix) {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
execGit(["init"], { cwd: repoDir });
execGit(["config", "user.name", "Test User"], { cwd: repoDir });
execGit(["config", "user.email", "test@example.com"], { cwd: repoDir });
return repoDir;
}

function writeRepoFile(repoDir, relativePath, content) {
const fullPath = path.join(repoDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}

function createExecApi(cwd) {
return {
async getExecOutput(command, args = [], options = {}) {
if (command !== "git") {
throw new Error(`unexpected command: ${command}`);
}
const result = execGit(args, { cwd, allowFailure: true });
if (result.status !== 0 && !options.ignoreReturnCode) {
throw new Error(result.stderr || result.stdout);
}
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
},
};
}

function fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName) {
execGit(["remote", "add", "origin", sourceRepo], { cwd: targetRepo });
execGit(["fetch", "origin", baseSha], { cwd: targetRepo });
execGit(["checkout", "-b", branchName, "FETCH_HEAD"], { cwd: targetRepo });
}

describe("push_to_pull_request_branch bundle integration", () => {
const tempDirs = [];

afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
vi.clearAllMocks();
});

it("lists files from a fetched bundle before applying it", async () => {
const branchName = "autoloop/simple-bundle";
const sourceRepo = createRepo("push-pr-bundle-source-");
const targetRepo = createRepo("push-pr-bundle-target-");
tempDirs.push(sourceRepo, targetRepo);

writeRepoFile(sourceRepo, "README.md", "base\n");
execGit(["add", "README.md"], { cwd: sourceRepo });
execGit(["commit", "-m", "base"], { cwd: sourceRepo });
execGit(["branch", "-M", "main"], { cwd: sourceRepo });
const baseSha = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim();

execGit(["checkout", "-b", branchName], { cwd: sourceRepo });
writeRepoFile(sourceRepo, ".changeset/fix.md", "patch\n");
writeRepoFile(sourceRepo, "docs/guide.md", "guide\n");
execGit(["add", ".changeset/fix.md", "docs/guide.md"], { cwd: sourceRepo });
execGit(["commit", "-m", "bundle change"], { cwd: sourceRepo });

const bundlePath = path.join(sourceRepo, "bundle.bundle");
execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo });

fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName);
const bundleRef = "refs/bundles/test-simple-bundle";
execGit(["fetch", bundlePath, `refs/heads/${branchName}:${bundleRef}`], { cwd: targetRepo });

const actualFiles = await getBundlePreApplyFiles(createExecApi(targetRepo), {}, baseSha, bundleRef);

expect(actualFiles.sort()).toEqual([".changeset/fix.md", "docs/guide.md"]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The integration tests exercise getBundlePreApplyFiles in isolation, which is great for unit-level confidence. But the original bug was a false positive abort caused by mismatch between the pre-check and post-apply check. The integration suite would be more valuable if it also included a test exercising the full push_to_pull_request_branch handler with a bundle whose patch artifact under-counts files — verifying the push is not aborted when all bundle files are in scope.

💡 Suggestion

Add a third integration test that:

  1. Creates a bundle containing .changeset/ and pkg/workflow/ files
  2. Constructs a patch artifact that only mentions the .changeset/ file (the original bug scenario)
  3. Runs the full handler with allowed_files: [".changeset/**", "pkg/workflow/**"]
  4. Asserts result.success === true (not aborted) and that the pre-apply info log shows 4 files

This would be a direct regression test for the root-cause scenario described in the PR.

});

it("includes files introduced through merge-commit bundle history", async () => {
const branchName = "autoloop/merge-bundle";
const sourceRepo = createRepo("push-pr-merge-source-");
const targetRepo = createRepo("push-pr-merge-target-");
tempDirs.push(sourceRepo, targetRepo);

writeRepoFile(sourceRepo, "README.md", "base\n");
execGit(["add", "README.md"], { cwd: sourceRepo });
execGit(["commit", "-m", "base"], { cwd: sourceRepo });
execGit(["branch", "-M", "main"], { cwd: sourceRepo });
const baseSha = execGit(["rev-parse", "HEAD"], { cwd: sourceRepo }).stdout.trim();

execGit(["checkout", "-b", "feature"], { cwd: sourceRepo });
writeRepoFile(sourceRepo, "feature.txt", "feature branch change\n");
execGit(["add", "feature.txt"], { cwd: sourceRepo });
execGit(["commit", "-m", "feature commit"], { cwd: sourceRepo });

execGit(["checkout", "main"], { cwd: sourceRepo });
writeRepoFile(sourceRepo, "main.txt", "main branch change\n");
execGit(["add", "main.txt"], { cwd: sourceRepo });
execGit(["commit", "-m", "main commit"], { cwd: sourceRepo });
execGit(["merge", "--no-ff", "feature", "-m", "merge feature"], { cwd: sourceRepo });
execGit(["checkout", "-b", branchName], { cwd: sourceRepo });

const bundlePath = path.join(sourceRepo, "merge.bundle");
execGit(["bundle", "create", bundlePath, `refs/heads/${branchName}`], { cwd: sourceRepo });

fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName);
const bundleRef = "refs/bundles/test-merge-bundle";
execGit(["fetch", bundlePath, `refs/heads/${branchName}:${bundleRef}`], { cwd: targetRepo });

const actualFiles = await getBundlePreApplyFiles(createExecApi(targetRepo), {}, baseSha, bundleRef);

expect(actualFiles.sort()).toEqual(["feature.txt", "main.txt"]);
});
});
64 changes: 64 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,70 @@ index 0000000..abc1234
}
});

it("should use authoritative bundle file detection before apply and match post-apply verification", async () => {
const bundlePath = canonicalBundlePath("feature-branch");
const patchPath = createPatchFile(
"feature-branch",
`From abc123 Mon Sep 17 00:00:00 2001
From: Test Author <test@example.com>
Date: Mon, 1 Jan 2024 00:00:00 +0000
Subject: [PATCH] Test commit

diff --git a/.changeset/patch-fix.md b/.changeset/patch-fix.md
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/.changeset/patch-fix.md
@@ -0,0 +1 @@
+content
--
2.34.1
`
);
fs.writeFileSync(bundlePath, "bundle content");

const pushSignedCommitsModule = require("./push_signed_commits.cjs");
const pushSignedSpy = vi.spyOn(pushSignedCommitsModule, "pushSignedCommits").mockResolvedValue("bundle-tip");

try {
const actualFiles = [".changeset/patch-fix.md", "pkg/workflow/pi_byok_env_passthrough_integration_test.go", "pkg/workflow/pi_engine.go", "pkg/workflow/pi_engine_test.go"];
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: "" });
}
if (cmd === "git" && args[0] === "rev-parse" && args[1] === "HEAD") {
return Promise.resolve({ exitCode: 0, stdout: "remote-head\n", stderr: "" });
}
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" && args[1] === bundlePath && options && options.ignoreReturnCode) {
return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
}
if (cmd === "git" && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--no-renames") {
return Promise.resolve({ exitCode: 0, stdout: `${actualFiles.join("\n")}\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: "" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The catch-all mock (return Promise.resolve({ exitCode: 0, ... })) silently swallows any unrecognised git command. If the implementation calls an unexpected git operation due to a bug, the test will still pass — exactly the failure mode we want tests to catch.

💡 Suggestion

Replace the catch-all with a rejecting fallback so unexpected calls surface immediately:

return Promise.reject(new Error(`Unexpected getExecOutput call: ${cmd} ${JSON.stringify(args)}`));

This follows the principle that test doubles should be as strict as the real contract.

});

const module = await loadModule();
const handler = await module.main({ allowed_files: [".changeset/**", "pkg/workflow/**"] });
const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {});

expect(result.success).toBe(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] This test only covers the happy path (all 4 files in scope → result.success === true). The pre-apply bundle check is a security-critical path that should also be covered by a test where the bundle contains files outside allowed_files and the push is denied.

💡 Missing test sketch
it("should deny push when bundle contains files outside allowed_files", async () => {
  // same setup, but actualFiles includes a file not matching allowed_files globs
  const actualFiles = [".changeset/patch-fix.md", "secrets/private.key"];
  mockExec.getExecOutput.mockImplementation((cmd, args) => {
    if (cmd === "git" && args[0] === "diff" && args[1] === "--name-only") {
      return Promise.resolve({ exitCode: 0, stdout: actualFiles.join("\n") + "\n", stderr: "" });
    }
    // ... other mocks
  });

  const module = await loadModule();
  const handler = await module.main({ allowed_files: [".changeset/**"] });
  const result = await handler({ branch: "feature-branch", diff_size: 5 * 1024 }, {});

  expect(result.success).toBe(false);
  expect(result.error).toContain("bundle modifies files outside the allowed-files list");
});

Without this, the security enforcement in the new bundle pre-check path has no regression test.

expect(mockCore.info).toHaveBeenCalledWith("Pre-apply bundle verification: 4 file(s) detected from bundle transport");

const diffCalls = mockExec.getExecOutput.mock.calls.filter(([, args]) => Array.isArray(args) && args[0] === "diff" && args[1] === "--name-only" && args[2] === "--no-renames");
expect(diffCalls.map(([, args]) => args[3])).toContain("remote-head..refs/bundles/push-feature-branch");
expect(diffCalls.map(([, args]) => args[3])).toContain("remote-head..HEAD");
} finally {
pushSignedSpy.mockRestore();
}
});

it("should use sanitized branch name (not agent-supplied message.branch) in bundle fetch refspec", async () => {
// The agent may supply a message.branch value; the bundle fetch must use the
// sanitized branchName from the GitHub API — never the raw agent input.
Expand Down
Loading