diff --git a/actions/setup/js/bundle_transport.integration.test.cjs b/actions/setup/js/bundle_transport.integration.test.cjs
index 97943f006b2..0be6b2a913c 100644
--- a/actions/setup/js/bundle_transport.integration.test.cjs
+++ b/actions/setup/js/bundle_transport.integration.test.cjs
@@ -5,8 +5,9 @@ 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 gitHelpers = require("./git_helpers.cjs");
+const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions } = gitHelpers;
+const { resolveTrustedBaseTip, resolveBundleTrustedBase, getBundleValidationBase, getBundlePreApplyFiles } = require("./push_to_pull_request_branch.cjs");
const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");
const gitEnv = () => {
@@ -28,6 +29,10 @@ function execApi(cwd, { emulateOldGit = false } = {}) {
const calls = [];
const api = {
calls,
+ async execGitSyncResult(args, options) {
+ const result = await api.getExecOutput("git", args, { ...options, silent: true, ignoreReturnCode: true });
+ return { ...result, status: result.exitCode };
+ },
async getExecOutput(command, args, options = {}) {
calls.push(args);
const env = { ...gitEnv(), ...options.env };
@@ -109,6 +114,49 @@ describe("offline bundle transport with real Git", () => {
return { oldHead, trustedBase, candidate };
}
+ it("recognizes the newest merged base without fetching an advanced trusted tip", async () => {
+ git(source, ["checkout", "-b", "feature"]);
+ const oldHead = addCommit("existing.txt", "PR work\n");
+ git(source, ["checkout", "main"]);
+ const olderBase = addCommit("older.txt", "older upstream\n");
+ git(source, ["checkout", "feature"]);
+ git(source, ["merge", "--no-ff", "main", "-m", "merge older base"]);
+ git(source, ["checkout", "main"]);
+ const mergedBase = addCommit("package.json", '{"upstream":true}\n');
+ git(source, ["checkout", "feature"]);
+ addCommit("agent.txt", "agent work\n");
+ git(source, ["merge", "--no-ff", "main", "-m", "merge newer base"]);
+ const bundle = path.join(root, "agent.bundle");
+ git(source, ["bundle", "create", bundle, "feature", `^${oldHead}`]);
+ const target = path.join(root, "target");
+ git(root, ["clone", source, target]);
+ git(source, ["checkout", "main"]);
+ const trustedTip = addCommit("later.txt", "base advances after agent finishes\n");
+ const exec = execApi(target);
+ await importLocalBundle(exec, bundle, "refs/heads/feature", "refs/bundles/test");
+ const before = packInventory(target);
+ expect(await resolveTrustedBaseTip(exec, {}, "main")).toBe(trustedTip);
+ const compareCommits = vi.fn(async ({ base, head }) => {
+ expect(base).toBe(trustedTip);
+ const result = spawnSync("git", ["merge-base", "--is-ancestor", head, base], { cwd: source, env: gitEnv() });
+ return { data: { status: result.status === 0 ? "behind" : "diverged" } };
+ });
+ const recognized = await resolveBundleTrustedBase(exec, {}, oldHead, "refs/bundles/test", trustedTip, { rest: { repos: { compareCommits } } }, { owner: "test-owner", repo: "test-repo" }, true);
+ expect(recognized).toBe(mergedBase);
+ expect(compareCommits.mock.calls.some(([args]) => args.head === olderBase)).toBe(false);
+ const baseline = await getBundleValidationBase(exec, {}, oldHead, "refs/bundles/test", recognized);
+ const files = await getBundlePreApplyFiles(exec, {}, baseline.ref, "refs/bundles/test", baseline.conflictedFiles);
+ expect(files).toEqual(["agent.txt"]);
+ expect(checkFileProtectionPostApply(files, { protected_files: ["package.json"] }).action).toBe("allow");
+ // Reachability permits the size baseline, not a protected-file exemption.
+ const protectionFiles = await getBundlePreApplyFiles(exec, {}, oldHead, "refs/bundles/test", baseline.conflictedFiles);
+ expect(checkFileProtectionPostApply(protectionFiles, { protected_files: ["package.json"] }).action).toBe("deny");
+ const absent = await exec.getExecOutput("git", ["cat-file", "-e", trustedTip], { ...offlineGitOptions(), ignoreReturnCode: true });
+ expect(absent.exitCode).not.toBe(0);
+ expect(packInventory(target)).toEqual(before);
+ expect(exec.calls.some(args => args[0] === "fetch")).toBe(false);
+ });
+
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"]);
@@ -145,6 +193,50 @@ describe("offline bundle transport with real Git", () => {
expect(fs.readFileSync(path.join(shallow, ".git/shallow"), "utf8")).toBe(shallowFile);
});
+ it.each([false, true])("keeps advanced-base discovery offline in a shallow checkout (filter support: %s)", async allowFilter => {
+ const oldHead = git(source, ["rev-parse", "HEAD"]);
+ git(source, ["checkout", "-b", "feature"]);
+ git(source, ["config", "uploadpack.allowFilter", String(allowFilter)]);
+ const shallow = path.join(root, "shallow");
+ git(root, ["clone", "--depth=1", "--filter=blob:none", "--no-checkout", "--branch", "feature", `file://${source}`, shallow]);
+ const shallowPath = path.join(shallow, ".git/shallow");
+ const shallowFile = fs.readFileSync(shallowPath, "utf8");
+ git(source, ["checkout", "main"]);
+ const mergedBase = addCommit("upstream.txt", "upstream\n");
+ git(source, ["checkout", "feature"]);
+ git(source, ["merge", "--no-ff", "main", "-m", "merge provisioned base"]);
+ addCommit("agent.txt", "agent\n");
+ const bundle = path.join(root, "shallow-agent.bundle");
+ git(source, ["bundle", "create", bundle, "feature", `^${oldHead}`]);
+ git(source, ["checkout", "main"]);
+ const trustedTip = addCommit("later.txt", "advanced base tip\n");
+
+ const exec = execApi(shallow);
+ await ensureFullHistoryForBundle(exec, {}, { bundleFilePath: bundle });
+ await importLocalBundle(exec, bundle, "refs/heads/feature", "refs/bundles/test");
+ const before = packInventory(shallow);
+ const compareCommits = vi.fn(async () => ({ data: { status: "behind" } }));
+ const options = { cwd: shallow, env: gitEnv() };
+ expect(await resolveTrustedBaseTip(gitHelpers, options, "main")).toBe(trustedTip);
+ const recognized = await resolveBundleTrustedBase(gitHelpers, options, oldHead, "refs/bundles/test", trustedTip, { rest: { repos: { compareCommits } } }, { owner: "test-owner", repo: "test-repo" }, true);
+ expect(recognized).toBe(mergedBase);
+ await getBundleValidationBase(gitHelpers, options, oldHead, "refs/bundles/test", recognized);
+
+ expect(compareCommits).toHaveBeenCalledTimes(1);
+ expect(exec.calls.some(args => args[0] === "fetch")).toBe(false);
+ expect(git(shallow, ["rev-parse", "--is-shallow-repository"])).toBe("true");
+ expect(fs.readFileSync(shallowPath, "utf8")).toBe(shallowFile);
+ expect(packInventory(shallow)).toEqual(before);
+ expect(fs.readdirSync(shallow)).toEqual([".git"]);
+ const absent = gitHelpers.execGitSyncResult(["cat-file", "-e", trustedTip], offlineGitOptions(options));
+ expect(absent.status).not.toBe(0);
+ if (allowFilter) {
+ const blob = git(source, ["rev-parse", `${oldHead}:shared.txt`]);
+ expect(gitHelpers.execGitSyncResult(["cat-file", "-e", blob], offlineGitOptions(options)).status).not.toBe(0);
+ }
+ expect(packInventory(shallow)).toEqual(before);
+ });
+
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");
diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs
index eef4fd0dec7..c391a2adf3b 100644
--- a/actions/setup/js/git_helpers.cjs
+++ b/actions/setup/js/git_helpers.cjs
@@ -1,7 +1,7 @@
// @ts-check
///
-const { spawnSync } = require("child_process");
+const childProcess = require("child_process");
const { ERR_SYSTEM } = require("./error_codes.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { isTransientError } = require("./error_recovery.cjs");
@@ -81,6 +81,35 @@ function ensureSafeDirectoryTrust(gitCwd, logger) {
debug?.(`Configured git safe.directory for bridge context: ${gitCwd}`);
}
+/**
+ * Execute Git without logging command output, preserving ordinary exit statuses.
+ * Spawn failures discard partial output; callers must check status before use.
+ *
+ * @param {string[]} args
+ * @param {Object} [options]
+ * @returns {import("child_process").SpawnSyncReturns}
+ */
+function execGitSyncResult(args, options = {}) {
+ const result = childProcess.spawnSync("git", args, {
+ encoding: "utf8",
+ maxBuffer: 100 * 1024 * 1024,
+ timeout: 60_000,
+ killSignal: "SIGKILL",
+ ...options,
+ stdio: options.stdio === "inherit" ? "pipe" : options.stdio,
+ env: {
+ ...(options.env || process.env),
+ GIT_TERMINAL_PROMPT: "0",
+ GCM_INTERACTIVE: "Never",
+ GIT_ASKPASS: "/bin/echo",
+ },
+ });
+ if (result.error || result.signal || result.status === null) {
+ return { ...result, status: null, stdout: "", stderr: "" };
+ }
+ return result;
+}
+
/**
* Safely execute git command using spawnSync with args array to prevent shell injection.
*
@@ -119,24 +148,9 @@ function execGitSync(args, options = {}) {
// credential rejection fail fast instead of opening an interactive prompt.
// - timeout (default 60s) ensures a stuck network/TLS handshake cannot
// wedge the calling event loop. Callers can override via options.timeout.
- const callerEnv = spawnOptions.env || process.env;
- const safeEnv = {
- ...callerEnv,
- GIT_TERMINAL_PROMPT: "0",
- GCM_INTERACTIVE: "Never",
- GIT_ASKPASS: "/bin/echo",
- };
const defaultTimeoutMs = 60_000;
- const result = spawnSync("git", args, {
- encoding: "utf8",
- maxBuffer: 100 * 1024 * 1024, // 100 MB — prevents ENOBUFS on large diffs (e.g. git format-patch)
- timeout: defaultTimeoutMs,
- killSignal: "SIGKILL",
- ...spawnOptions,
- stdio: spawnOptions.stdio === "inherit" ? "pipe" : spawnOptions.stdio,
- env: safeEnv,
- });
+ const result = execGitSyncResult(args, spawnOptions);
if (result.error) {
// Detect ENOBUFS (buffer overflow) and provide a more actionable message
@@ -873,6 +887,7 @@ module.exports = {
isTransientGitError,
withGitRetry,
execGitSync,
+ execGitSyncResult,
backfillCommitObjects,
checkImplausibleShallowRange,
ensureFullHistoryForBundle,
diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs
index 6714e2b2590..c334c457721 100644
--- a/actions/setup/js/git_helpers.test.cjs
+++ b/actions/setup/js/git_helpers.test.cjs
@@ -43,6 +43,26 @@ describe("git_helpers.cjs", () => {
});
describe("execGitSync", () => {
+ it("exposes structured Git results without logging remote diagnostics", async () => {
+ const { execGitSyncResult } = await import("./git_helpers.cjs");
+ const logger = { debug: vi.fn(), error: vi.fn() };
+ global.core = logger;
+ const result = execGitSyncResult(["invalid-command"]);
+ expect(result.status).not.toBe(0);
+ expect(result.stderr).toContain("invalid-command");
+ expect(logger.debug).not.toHaveBeenCalled();
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+
+ it("discards partial structured output on process failure", async () => {
+ const { execGitSyncResult } = await import("./git_helpers.cjs");
+ const result = execGitSyncResult(["--version"], { maxBuffer: 1 });
+ expect(result.status).toBeNull();
+ expect(result.error.code).toBe("ENOBUFS");
+ expect(result.stdout).toBe("");
+ expect(result.stderr).toBe("");
+ });
+
it("should export execGitSync function", async () => {
const { execGitSync } = await import("./git_helpers.cjs");
expect(typeof execGitSync).toBe("function");
diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs
index ae364d58d42..226f417a925 100644
--- a/actions/setup/js/push_to_pull_request_branch.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.cjs
@@ -19,11 +19,12 @@ const { detectForkPR, checkBranchPushable } = require("./pr_helpers.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { checkFileProtection, checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");
-const { POLICY_FILE_PROTECTION_DENIED_REASON_CODE } = require("./error_codes.cjs");
+const { ERR_SYSTEM, 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, importLocalBundle, offlineGitOptions, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs");
+const gitHelpers = require("./git_helpers.cjs");
+const { ensureFullHistoryForBundle, importLocalBundle, offlineGitOptions, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = gitHelpers;
const { extractPatchBaseCommit } = require("./commit_sha_helpers.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { getThreatWarningPresentation } = require("./threat_detection_warning.cjs");
@@ -37,6 +38,17 @@ const { buildManualBranchApplyCommands, withTransientPushRetry } = require("./cr
/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "push_to_pull_request_branch";
+const MAX_BUNDLE_PARENT_COMPARISONS = 4;
+const MAX_BUNDLE_HISTORY_COMMITS = 100;
+const BUNDLE_VALIDATION_TIMEOUT_MS = 5000;
+const BUNDLE_VALIDATION_MAX_OUTPUT = 1024 * 1024;
+
+/**
+ * @param {Record} options
+ */
+function validationGitOptions(options) {
+ return { cwd: options.cwd, env: options.env, timeout: BUNDLE_VALIDATION_TIMEOUT_MS, maxBuffer: BUNDLE_VALIDATION_MAX_OUTPUT, killSignal: "SIGKILL", stdio: "pipe" };
+}
const MISSING_BRANCH_ERROR_TEMPLATE = branchName => `Branch ${branchName} no longer exists on origin (it may have been deleted), can't push to it.`;
const MISSING_REMOTE_REF_PATTERNS = [
"couldn't find remote ref",
@@ -133,30 +145,30 @@ async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef,
* markers remain in the tree. Track conflicted paths too: binary and modify/delete
* conflicts may keep one side unchanged without inserting conflict markers.
*
- * @param {{ getExecOutput: Function }} exec
+ * @param {{ execGitSyncResult: Function }} git
* @param {Record} gitOptions
* @param {string} oldHead
* @param {string} candidate
* @param {string} trustedBase
* @returns {Promise<{ ref: string, conflictedFiles: string[] }>}
*/
-async function getBundleValidationBase(exec, gitOptions, oldHead, candidate, trustedBase) {
+async function getBundleValidationBase(git, gitOptions, oldHead, candidate, trustedBase) {
const fallback = { ref: oldHead, conflictedFiles: [] };
if (!trustedBase) return fallback;
try {
- 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);
- if (oldAncestor.exitCode !== 0 || baseAncestor.exitCode !== 0 || alreadyMerged.exitCode !== 1) return fallback;
+ const opts = validationGitOptions(offlineGitOptions(gitOptions));
+ const oldAncestor = await git.execGitSyncResult(["merge-base", "--is-ancestor", oldHead, candidate], opts);
+ const baseAncestor = await git.execGitSyncResult(["merge-base", "--is-ancestor", trustedBase, candidate], opts);
+ const alreadyMerged = await git.execGitSyncResult(["merge-base", "--is-ancestor", trustedBase, oldHead], opts);
+ if (oldAncestor.status !== 0 || baseAncestor.status !== 0 || alreadyMerged.status !== 1) return fallback;
- const merge = await exec.getExecOutput("git", ["merge-tree", "--write-tree", "--name-only", "-z", oldHead, trustedBase], opts);
+ const merge = await git.execGitSyncResult(["merge-tree", "--write-tree", "--name-only", "-z", oldHead, trustedBase], opts);
const [tree, ...fields] = merge.stdout.split("\0");
- if ((merge.exitCode === 0 || merge.exitCode === 1) && /^[0-9a-f]{40}$/.test(tree)) {
+ if ((merge.status === 0 || merge.status === 1) && /^[0-9a-f]{40}$/.test(tree)) {
const separatorIndex = fields.indexOf("");
return {
ref: tree,
- conflictedFiles: merge.exitCode === 1 ? fields.slice(0, separatorIndex === -1 ? fields.length : separatorIndex) : [],
+ conflictedFiles: merge.status === 1 ? fields.slice(0, separatorIndex === -1 ? fields.length : separatorIndex) : [],
};
}
core.warning("Unable to compute trusted base merge tree; using PR-head validation");
@@ -172,25 +184,25 @@ async function getBundleValidationBase(exec, gitOptions, oldHead, candidate, tru
* 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 {{ execGitSyncResult: Function }} git
* @param {Object} gitOptions
* @param {unknown} baseRef
* @returns {Promise}
*/
-async function resolveTrustedBaseTip(exec, gitOptions, baseRef) {
+async function resolveTrustedBaseTip(git, 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 };
+ const opts = validationGitOptions({ ...gitOptions, env: { ...process.env, ...gitOptions.env } });
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 valid = await git.execGitSyncResult(["check-ref-format", ref], validationGitOptions(offlineGitOptions(gitOptions)));
+ if (valid.status !== 0) throw new Error("Invalid base ref");
+ const result = await git.execGitSyncResult(["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) {
+ if (result.status === 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];
}
@@ -201,6 +213,81 @@ async function resolveTrustedBaseTip(exec, gitOptions, baseRef) {
return "";
}
+/**
+ * Recognize a newly merged ancestor of the pinned remote tip for size checks.
+ * Reachability does not prove a historical base-branch tip: only the exact pinned
+ * tip can exempt protected files or history. Topological order prefers newer
+ * parents without relying on timestamps. All object inspection stays offline.
+ *
+ * @param {{ execGitSyncResult: Function }} git
+ * @param {Record} gitOptions
+ * @param {string} oldHead
+ * @param {string} candidate
+ * @param {string} trustedTip
+ * @param {typeof github} githubClient
+ * @param {{ owner: string, repo: string }} repoParts
+ * @returns {Promise}
+ */
+async function resolveBundleTrustedBase(git, gitOptions, oldHead, candidate, trustedTip, githubClient, repoParts, allowMergedBaseBaseline = false) {
+ if (!trustedTip) return "";
+ const opts = validationGitOptions(offlineGitOptions(gitOptions));
+ try {
+ const tipAncestor = await git.execGitSyncResult(["merge-base", "--is-ancestor", trustedTip, candidate], opts);
+ if (tipAncestor.status === 0) return trustedTip;
+ if (!allowMergedBaseBaseline) {
+ core.debug("Bundle validation: merged base ancestor discovery is disabled");
+ return "";
+ }
+
+ const merges = await git.execGitSyncResult(["rev-list", "--merges", "--parents", `--max-count=${MAX_BUNDLE_HISTORY_COMMITS + 1}`, `${oldHead}..${candidate}`], opts);
+ if (merges.status !== 0) return "";
+ const mergeLines = merges.stdout.trim().split("\n").filter(Boolean);
+ if (mergeLines.length > MAX_BUNDLE_HISTORY_COMMITS || mergeLines.some(line => line.split(" ").length > 3)) {
+ core.debug("Bundle validation: merge history exceeds the discovery budget");
+ return "";
+ }
+ const parents = new Set(mergeLines.flatMap(line => line.split(" ").slice(1)).filter(sha => /^[0-9a-f]{40}$/.test(sha)));
+ if (!parents.size) return "";
+ const ordered = await git.execGitSyncResult(["rev-list", "--topo-order", `--max-count=${MAX_BUNDLE_HISTORY_COMMITS + 1}`, ...parents, "--not", oldHead], opts);
+ if (ordered.status !== 0) return "";
+ const orderedParents = ordered.stdout.trim().split("\n").filter(Boolean);
+ if (orderedParents.length > MAX_BUNDLE_HISTORY_COMMITS) {
+ core.debug("Bundle validation: parent history exceeds the discovery budget");
+ return "";
+ }
+ let comparisonAttempts = 0;
+ for (const parent of orderedParents) {
+ if (!parents.has(parent)) continue;
+ if (comparisonAttempts >= MAX_BUNDLE_PARENT_COMPARISONS) {
+ core.debug("Bundle validation: merge parent comparison budget exhausted");
+ break;
+ }
+ comparisonAttempts++;
+ try {
+ // Comparing tip...parent reports "behind" when parent is an ancestor.
+ const { data } = await githubClient.rest.repos.compareCommits({
+ ...repoParts,
+ base: trustedTip,
+ head: parent,
+ per_page: 1,
+ request: { timeout: BUNDLE_VALIDATION_TIMEOUT_MS, retries: 0, signal: AbortSignal.timeout(BUNDLE_VALIDATION_TIMEOUT_MS) },
+ });
+ if (data.status === "behind" || data.status === "identical") return parent;
+ } catch (error) {
+ core.debug("Bundle validation: merge parent comparison unavailable");
+ // Only a missing parent is candidate-specific; other failures stop this optional check.
+ if (typeof error === "object" && error !== null && "status" in error && error.status === 404) continue;
+ break;
+ }
+ }
+ } catch {
+ // Missing local history must not trigger a fetch or expose remote diagnostics.
+ core.debug("Bundle validation: merged base history unavailable");
+ }
+ core.debug("Bundle validation: no trusted merged base ancestor confirmed; using PR-head validation");
+ return "";
+}
+
/**
* Measure the expanded blob size of files changed by the applied agent commits.
* Deleted files contribute zero bytes because no new content is being introduced.
@@ -400,6 +487,7 @@ async function main(config = {}) {
const fallbackAsPullRequest = config.fallback_as_pull_request !== false;
const checkBranchProtection = config.check_branch_protection !== false;
const signedCommits = config.signed_commits !== false;
+ const allowMergedBaseBaseline = config.allow_merged_base_baseline === true;
const commitTitleSuffix = config.commit_title_suffix || "";
const maxSizeKb = parsePositiveInteger(config.max_patch_size) ?? 4096;
const maxCount = config.max || 0; // 0 means no limit
@@ -509,6 +597,10 @@ async function main(config = {}) {
const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath));
const applyTransport = hasBundleFile ? "bundle" : "patch";
const deferBundleValidation = hasBundleFile && !signedCommits && !isStaged;
+ const mergedBaseBaselineHint =
+ hasBundleFile && !signedCommits && !allowMergedBaseBaseline
+ ? ". If the bundle includes commits already merged into the current base branch, set allow-merged-base-baseline: true under safe-outputs.push-to-pull-request-branch to recognize the merged base ancestor."
+ : "";
core.info(`Apply transport mode: ${applyTransport} (patch file present: ${hasPatchFile}, bundle file present: ${hasBundleFile})`);
if (bundleFilePath && !hasBundleFile) {
core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`);
@@ -590,8 +682,7 @@ async function main(config = {}) {
core.info(`${sizeLabel}: ${sizeForCheckKb} KB (maximum allowed: ${maxSizeKb} KB)`);
if (sizeForCheckKb > maxSizeKb) {
- let msg;
- msg = `Patch size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
+ const msg = `Patch size (${sizeForCheckKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)${mergedBaseBaselineHint}`;
return { error: { success: false, error: msg }, fallbackFiles };
}
@@ -1069,6 +1160,7 @@ async function main(config = {}) {
let pushedCommitSha = "";
let rangeBaseRef = branchRemoteRef;
let validationBaseRef = branchRemoteRef;
+ let protectionBaseRef = branchRemoteRef;
let validationConflictFiles = [];
if (hasChanges) {
// Capture HEAD before applying changes to compute new-commit count later
@@ -1122,7 +1214,7 @@ async function main(config = {}) {
core.info(`Applying changes from bundle: ${bundleFilePath}`);
const bundleRef = `refs/bundles/push-${branchName.replace(/[^a-zA-Z0-9-]/g, "-")}`;
try {
- const trustedBase = signedCommits ? "" : await resolveTrustedBaseTip(exec, baseGitOpts, pullRequest.base?.ref);
+ const trustedTip = signedCommits ? "" : await resolveTrustedBaseTip(gitHelpers, baseGitOpts, pullRequest.base?.ref);
const bundleGitOpts = offlineGitOptions(baseGitOpts);
await ensureFullHistoryForBundle(
exec,
@@ -1140,7 +1232,8 @@ async function main(config = {}) {
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);
+ const trustedBase = await resolveBundleTrustedBase(gitHelpers, baseGitOpts, rangeBaseRef, bundleRef, trustedTip, githubClient, repoParts, allowMergedBaseBaseline);
+ const validationBase = await getBundleValidationBase(gitHelpers, baseGitOpts, rangeBaseRef, bundleRef, trustedBase);
validationBaseRef = validationBase.ref;
validationConflictFiles = validationBase.conflictedFiles;
const trustedBaseInValidationBase = validationBaseRef !== rangeBaseRef;
@@ -1149,10 +1242,18 @@ async function main(config = {}) {
} else {
core.info("Bundle validation baseline uses PR head");
}
- const agentHistoryExclusions = [rangeBaseRef];
- if (trustedBaseInValidationBase) agentHistoryExclusions.push(trustedBase);
+ const sizeHistoryExclusions = [rangeBaseRef];
+ if (trustedBaseInValidationBase) sizeHistoryExclusions.push(trustedBase);
+ // An ancestor may be an intermediate side-branch state that was reverted
+ // before reaching the base branch. Only the pinned tip has state provenance.
+ const hasTrustedBaseState = trustedBaseInValidationBase && trustedBase === trustedTip;
+ protectionBaseRef = hasTrustedBaseState ? validationBaseRef : rangeBaseRef;
+ const agentHistoryExclusions = hasTrustedBaseState ? sizeHistoryExclusions : [rangeBaseRef];
+ if (trustedBaseInValidationBase && !hasTrustedBaseState) {
+ core.info("Bundle file protection uses PR head: merged ancestor is not the pinned base tip");
+ }
if (deferBundleValidation) {
- const agentPatchStream = await exec.getExecOutput("git", ["log", "--format=", "--patch", "--binary", "--no-ext-diff", "--no-textconv", "--diff-merges=remerge", bundleRef, "--not", ...agentHistoryExclusions], bundleGitOpts);
+ const agentPatchStream = await exec.getExecOutput("git", ["log", "--format=", "--patch", "--binary", "--no-ext-diff", "--no-textconv", "--diff-merges=remerge", bundleRef, "--not", ...sizeHistoryExclusions], bundleGitOpts);
const validation = validatePatch(agentPatchStream.stdout);
if (validation.error) return validation.error;
if (validation.fallbackFiles.length) return await createProtectedFilesFallbackIssue(validation.fallbackFiles);
@@ -1164,14 +1265,14 @@ 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, bundleGitOpts, validationBaseRef, bundleRef, validationConflictFiles);
+ let bundleFiles = await getBundlePreApplyFiles(exec, bundleGitOpts, protectionBaseRef, bundleRef, validationConflictFiles);
if (deferBundleValidation) {
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.
+ // Preserve changes that were later reverted. Ancestor reachability
+ // alone must not exempt side-branch commits from file protection.
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)])];
}
@@ -1331,7 +1432,7 @@ async function main(config = {}) {
}
}
} // end else (patch path)
- if (!hasBundleFile) validationBaseRef = rangeBaseRef;
+ if (!hasBundleFile) validationBaseRef = protectionBaseRef = rangeBaseRef;
core.info(`Apply transport completed; signed-push base ref: ${rangeBaseRef}`);
// POST-APPLY FILE PROTECTION: Verify actual files written match policy.
@@ -1343,9 +1444,10 @@ async function main(config = {}) {
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) {
- core.info(`Post-apply verification: ${actualFiles.length} file(s) actually modified`);
- const postApplyProtection = checkFileProtectionPostApply(actualFiles, config);
+ const protectionFiles = protectionBaseRef === validationBaseRef ? actualFiles : await getBundlePreApplyFiles(exec, baseGitOpts, protectionBaseRef, "HEAD", validationConflictFiles);
+ if (protectionFiles.length > 0) {
+ core.info(`Post-apply verification: ${protectionFiles.length} file(s) actually modified`);
+ const postApplyProtection = checkFileProtectionPostApply(protectionFiles, config);
if (postApplyProtection.action === "deny") {
const filesStr = postApplyProtection.files.join(", ");
const msg = `SECURITY: Post-apply file-protection check failed. ` + `The patch applied files that were not detected by the pre-apply parser: ${filesStr}. ` + `This may indicate a patch-parser bypass attempt. Aborting push.`;
@@ -1359,12 +1461,13 @@ async function main(config = {}) {
await exec.exec("git", ["reset", "--hard", rangeBaseRef], baseGitOpts);
return await createProtectedFilesFallbackIssue(postApplyProtection.files);
}
-
+ }
+ if (actualFiles.length > 0) {
const changedBlobSizeBytes = getChangedBlobSizeBytes(baseGitOpts, actualFiles);
const changedBlobSizeKb = Math.ceil(changedBlobSizeBytes / 1024);
core.info(`Changed content size: ${changedBlobSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
if (changedBlobSizeKb > maxSizeKb) {
- const msg = `Changed content size (${changedBlobSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
+ const msg = `Changed content size (${changedBlobSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)${mergedBaseBaselineHint}`;
await exec.exec("git", ["reset", "--hard", rangeBaseRef], baseGitOpts);
return { success: false, error: msg };
}
@@ -1821,4 +1924,4 @@ async function main(config = {}) {
});
}
-module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles, getBundleValidationBase, resolveTrustedBaseTip };
+module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles, getBundleValidationBase, resolveTrustedBaseTip, resolveBundleTrustedBase };
diff --git a/actions/setup/js/push_to_pull_request_branch.integration.test.cjs b/actions/setup/js/push_to_pull_request_branch.integration.test.cjs
index 8529dbe467f..e7dc6685bcd 100644
--- a/actions/setup/js/push_to_pull_request_branch.integration.test.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.integration.test.cjs
@@ -6,7 +6,7 @@ import path from "path";
import { spawnSync } from "child_process";
const require = createRequire(import.meta.url);
-const { getBundlePreApplyFiles, getBundleValidationBase } = require("./push_to_pull_request_branch.cjs");
+const { getBundlePreApplyFiles, getBundleValidationBase, resolveBundleTrustedBase } = require("./push_to_pull_request_branch.cjs");
const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs");
global.core = {
@@ -45,18 +45,26 @@ function writeRepoFile(repoDir, relativePath, content) {
}
function createExecApi(cwd) {
- return {
+ const api = {
+ async execGitSyncResult(args, options) {
+ const result = await api.getExecOutput("git", args, { ...options, silent: true, ignoreReturnCode: true });
+ return { ...result, status: result.exitCode };
+ },
async getExecOutput(command, args = [], options = {}) {
if (command !== "git") {
throw new Error(`unexpected command: ${command}`);
}
- const result = execGit(args, { cwd, allowFailure: true });
+ const result = execGit(args, { cwd, env: { ...process.env, ...options.env }, 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 };
},
+ async exec(command, args = [], options = {}) {
+ return (await api.getExecOutput(command, args, options)).exitCode;
+ },
};
+ return api;
}
function fetchBaseCommit(targetRepo, sourceRepo, baseSha, branchName) {
@@ -79,7 +87,10 @@ describe("push_to_pull_request_branch bundle integration", () => {
[false, false],
[true, false],
[false, true],
- ])("excludes trusted base changes while counting agent changes (conflict: %s, reverted change: %s)", async (conflict, revertedChange) => {
+ [false, false, true],
+ [true, false, true],
+ [false, true, true],
+ ])("excludes base changes from size but requires tip provenance for protection (conflict: %s, reverted change: %s, advanced base: %s)", async (conflict, revertedChange, baseAdvanced = false) => {
const repo = createRepo("push-pr-validation-base-");
tempDirs.push(repo);
const git = args => execGit(args, { cwd: repo });
@@ -119,7 +130,24 @@ describe("push_to_pull_request_branch bundle integration", () => {
git(["commit", "-m", "agent work"]);
const exec = createExecApi(repo);
- const { ref: baseline, conflictedFiles } = await getBundleValidationBase(exec, {}, oldHead, "HEAD", trustedBase);
+ let trustedTip = trustedBase;
+ if (baseAdvanced) {
+ git(["checkout", "main"]);
+ writeRepoFile(repo, "later.txt", "later base work\n");
+ git(["add", "."]);
+ git(["commit", "-m", "base advances after agent merge"]);
+ trustedTip = git(["rev-parse", "HEAD"]).stdout.trim();
+ git(["checkout", "feature"]);
+ }
+ const compareCommits = vi.fn(async ({ base, head }) => {
+ expect(base).toBe(trustedTip);
+ const ancestor = execGit(["merge-base", "--is-ancestor", head, base], { cwd: repo, allowFailure: true });
+ return { data: { status: ancestor.status === 0 ? "behind" : "diverged" } };
+ });
+ const recognizedBase = await resolveBundleTrustedBase(exec, {}, oldHead, "HEAD", trustedTip, { rest: { repos: { compareCommits } } }, { owner: "test-owner", repo: "test-repo" }, true);
+ expect(recognizedBase).toBe(trustedBase);
+ expect(compareCommits).toHaveBeenCalledTimes(baseAdvanced ? 1 : 0);
+ const { ref: baseline, conflictedFiles } = await getBundleValidationBase(exec, {}, oldHead, "HEAD", recognizedBase);
expect(baseline).not.toBe(oldHead);
const files = await getBundlePreApplyFiles(exec, {}, baseline, "HEAD", conflictedFiles);
expect(files.sort()).toEqual(conflict ? ["agent.txt", "conflict.txt"] : ["agent.txt"]);
@@ -130,9 +158,22 @@ describe("push_to_pull_request_branch bundle integration", () => {
expect(Buffer.byteLength(agentPatch)).toBeLessThan(1024);
expect(files.reduce((size, file) => size + fs.statSync(path.join(repo, file)).size, 0)).toBeLessThan(1024);
expect(git(["rev-list", "--count", "--merges", `${oldHead}..HEAD`]).stdout.trim()).toBe("1");
- const historyFiles = git(["log", "--format=", "--name-only", "--no-renames", "--diff-merges=remerge", "-z", "HEAD", "--not", oldHead, trustedBase]).stdout.split("\0").filter(Boolean);
- expect([...new Set(historyFiles)].sort()).toEqual(revertedChange ? ["agent.txt", "package.json"] : conflict ? ["agent.txt", "conflict.txt"] : ["agent.txt"]);
- expect(checkFileProtectionPostApply([...files, ...historyFiles], { protected_files: ["package.json"] }).action).toBe(revertedChange ? "deny" : "allow");
+ const historyExclusions = baseAdvanced ? [oldHead] : [oldHead, trustedBase];
+ const historyFiles = git(["log", "--format=", "--name-only", "--no-renames", "--diff-merges=remerge", "-z", "HEAD", "--not", ...historyExclusions])
+ .stdout.split("\0")
+ .filter(Boolean);
+ expect([...new Set(historyFiles)].sort()).toEqual(
+ baseAdvanced
+ ? conflict
+ ? ["agent.txt", "conflict.txt", "package.json", "upstream.txt"]
+ : ["agent.txt", "package.json", "upstream.txt"]
+ : revertedChange
+ ? ["agent.txt", "package.json"]
+ : conflict
+ ? ["agent.txt", "conflict.txt"]
+ : ["agent.txt"]
+ );
+ expect(checkFileProtectionPostApply([...files, ...historyFiles], { protected_files: ["package.json"] }).action).toBe(revertedChange || baseAdvanced ? "deny" : "allow");
});
it.each([false, true])("detects protected changes made and restored only in agent merge commits (octopus: %s)", async octopus => {
@@ -194,6 +235,122 @@ describe("push_to_pull_request_branch bundle integration", () => {
expect(checkFileProtectionPostApply([...files, ...historyFiles], { protected_files: ["package.json"] }).action).toBe("deny");
});
+ it.each([
+ [false, false],
+ [true, false],
+ [false, true],
+ ])("rejects unsigned bundles importing a non-tip side ancestor (side reverted: %s, agent reverted: %s)", async (sideReverted, agentReverted) => {
+ const sourceRepo = createRepo("push-pr-side-ancestor-source-");
+ const targetRepo = createRepo("push-pr-side-ancestor-target-");
+ tempDirs.push(sourceRepo, targetRepo);
+ const git = args => execGit(args, { cwd: sourceRepo });
+ writeRepoFile(sourceRepo, "package.json", "{}\n");
+ git(["add", "."]);
+ git(["commit", "-m", "common base"]);
+ git(["branch", "-M", "main"]);
+ git(["checkout", "-b", "feature"]);
+ writeRepoFile(sourceRepo, "existing.txt", "existing PR\n");
+ git(["add", "."]);
+ git(["commit", "-m", "existing PR"]);
+ const oldHead = git(["rev-parse", "HEAD"]).stdout.trim();
+ git(["checkout", "-b", "side", "main"]);
+ writeRepoFile(sourceRepo, "package.json", '{"scripts":{"review-regression":"echo regression"}}\n');
+ git(["commit", "-am", "A adds protected script"]);
+ const added = git(["rev-parse", "HEAD"]).stdout.trim();
+ writeRepoFile(sourceRepo, "package.json", "{}\n");
+ git(["commit", "-am", "B removes protected script"]);
+ const removed = git(["rev-parse", "HEAD"]).stdout.trim();
+ git(["checkout", "main"]);
+ git(["merge", "--no-ff", "side", "-m", "merge side after script removal"]);
+ const trustedTip = git(["rev-parse", "HEAD"]).stdout.trim();
+ expect(git(["show", "main:package.json"]).stdout).toBe("{}\n");
+ expect(git(["rev-list", "--first-parent", "main"]).stdout).not.toContain(added);
+ const mergedAncestor = sideReverted ? removed : added;
+ git(["checkout", "-b", "candidate", "feature"]);
+ git(["merge", "--no-ff", mergedAncestor, "-m", "merge intermediate side state"]);
+ if (agentReverted) {
+ writeRepoFile(sourceRepo, "package.json", "{}\n");
+ git(["commit", "-am", "agent restores protected file"]);
+ }
+ writeRepoFile(sourceRepo, "agent.txt", "small agent change\n");
+ git(["add", "."]);
+ git(["commit", "-m", "agent change"]);
+ const candidate = git(["rev-parse", "HEAD"]).stdout.trim();
+ const bundlePath = path.join(sourceRepo, ".git", "candidate.bundle");
+ const patchPath = path.join(sourceRepo, ".git", "candidate.patch");
+ git(["bundle", "create", bundlePath, "HEAD", `^${oldHead}`]);
+ fs.writeFileSync(patchPath, git(["format-patch", "--stdout", `${oldHead}..HEAD`]).stdout);
+ execGit(["fetch", sourceRepo, "main:refs/remotes/origin/main", "feature:refs/remotes/origin/feature"], { cwd: targetRepo });
+ execGit(["remote", "add", "origin", sourceRepo], { cwd: targetRepo });
+ const exec = createExecApi(targetRepo);
+ exec.getExecOutput = vi.fn(exec.getExecOutput);
+ const originalEnv = { ...process.env };
+ const originalGlobals = { exec: global.exec, github: global.github, context: global.context };
+ const paths = require("./resolve_transport_paths.cjs");
+ const transportPaths = vi.spyOn(paths, "resolveTransportPaths").mockReturnValue({ patchPath, bundlePath });
+ const push = vi.spyOn(require("./push_signed_commits.cjs"), "pushSignedCommits").mockRejectedValue(new Error("Unexpected push"));
+ let boundedOutputSpy;
+ try {
+ process.env.GITHUB_REPOSITORY = "test-owner/test-repo";
+ process.env.GITHUB_WORKSPACE = targetRepo;
+ global.exec = exec;
+ global.context = { repo: { owner: "test-owner", repo: "test-repo" }, payload: { pull_request: { number: 123 } } };
+ const compareCommits = vi.fn(async ({ base, head }) => {
+ expect(base).toBe(trustedTip);
+ const result = execGit(["merge-base", "--is-ancestor", head, base], { cwd: sourceRepo, allowFailure: true });
+ return { data: { status: result.status === 0 ? "behind" : "diverged" } };
+ });
+ global.github = {
+ rest: {
+ pulls: {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ head: { ref: "feature", sha: oldHead, repo: { full_name: "test-owner/test-repo", fork: false } },
+ base: { ref: "main", repo: { full_name: "test-owner/test-repo" } },
+ labels: [],
+ },
+ }),
+ },
+ repos: { get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }), compareCommits },
+ },
+ };
+ delete require.cache[require.resolve("./push_to_pull_request_branch.cjs")];
+ const module = require("./push_to_pull_request_branch.cjs");
+ const gitHelpers = require("./git_helpers.cjs");
+ const boundedOutput = gitHelpers.execGitSyncResult;
+ // Match the fixture's exec API, which defaults all Git calls to targetRepo.
+ boundedOutputSpy = vi.spyOn(gitHelpers, "execGitSyncResult").mockImplementation((args, options) => boundedOutput(args, { ...options, cwd: targetRepo }));
+ const handler = await module.main({
+ signed_commits: false,
+ check_branch_protection: false,
+ allow_merged_base_baseline: true,
+ protected_files: ["package.json"],
+ });
+ const result = await handler({ branch: "feature" }, {});
+ expect(result).toMatchObject({ success: false, skipped: true });
+ expect(result.error).toContain(`${agentReverted ? "patch" : "bundle"} modifies protected files (package.json)`);
+ expect(compareCommits).toHaveBeenCalledWith({ owner: "test-owner", repo: "test-repo", base: trustedTip, head: mergedAncestor, per_page: 1, request: { timeout: 5000, retries: 0, signal: expect.any(AbortSignal) } });
+ if (!agentReverted) {
+ expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["log", "--format=", "--name-only", "--no-renames", "--diff-merges=remerge", "-z", "refs/bundles/push-feature", "--not", oldHead], expect.any(Object));
+ }
+ expect(execGit(["rev-parse", "feature"], { cwd: targetRepo }).stdout.trim()).toBe(oldHead);
+ expect(execGit(["rev-parse", "origin/feature"], { cwd: targetRepo }).stdout.trim()).toBe(oldHead);
+ expect(execGit(["show", `${candidate}:package.json`], { cwd: targetRepo }).stdout).toBe(sideReverted || agentReverted ? "{}\n" : '{"scripts":{"review-regression":"echo regression"}}\n');
+ expect(exec.getExecOutput.mock.calls.filter(([, args]) => args[0] === "fetch").map(([, args]) => args)).toEqual([["fetch", "origin", "feature:refs/remotes/origin/feature"]]);
+ expect(push).not.toHaveBeenCalled();
+ } finally {
+ transportPaths.mockRestore();
+ push.mockRestore();
+ boundedOutputSpy?.mockRestore();
+ Object.assign(global, originalGlobals);
+ for (const key of Object.keys(process.env)) {
+ if (!(key in originalEnv)) delete process.env[key];
+ }
+ Object.assign(process.env, originalEnv);
+ delete require.cache[require.resolve("./push_to_pull_request_branch.cjs")];
+ }
+ });
+
it.each(["text", "binary", "modify/delete"])("keeps protected %s conflict resolutions in the validation file set", async kind => {
const repo = createRepo("push-pr-protected-resolution-");
tempDirs.push(repo);
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 17da0204bdb..64a218fe3e6 100644
--- a/actions/setup/js/push_to_pull_request_branch.test.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.test.cjs
@@ -217,6 +217,10 @@ describe("push_to_pull_request_branch.cjs", () => {
graphql: vi.fn(),
};
+ mockExec.execGitSyncResult = async (args, options) => {
+ const result = await mockExec.getExecOutput("git", args, { ...options, silent: true, ignoreReturnCode: true });
+ return { ...result, status: result.exitCode };
+ };
global.core = mockCore;
global.exec = mockExec;
global.context = mockContext;
@@ -248,6 +252,7 @@ describe("push_to_pull_request_branch.cjs", () => {
delete global.exec;
delete global.context;
delete global.github;
+ vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -257,6 +262,7 @@ describe("push_to_pull_request_branch.cjs", () => {
async function loadModule() {
// Module loads when imported
const module = require("./push_to_pull_request_branch.cjs");
+ vi.spyOn(require("./git_helpers.cjs"), "execGitSyncResult").mockImplementation((args, options) => mockExec.execGitSyncResult(args, options));
return module;
}
@@ -346,6 +352,46 @@ index 0000000..abc1234
return patchPath;
}
+ describe("merged base provenance", () => {
+ const trustedTip = "a".repeat(40);
+ const mergedBase = "b".repeat(40);
+
+ it.each(["behind", "identical", "ahead", "diverged", "unknown", "error"])("uses only confirmed ancestors of the pinned tip (compare status: %s)", async status => {
+ mockExec.getExecOutput.mockImplementation(async (_cmd, args) => {
+ if (args[0] === "merge-base") return { exitCode: 128, stdout: "", stderr: "tip object unavailable" };
+ if (args[1] === "--merges") return { exitCode: 0, stdout: `${"c".repeat(40)} ${mergedBase} ${mergedBase}\n`, stderr: "" };
+ return { exitCode: 0, stdout: `${mergedBase}\n`, stderr: "" };
+ });
+ mockGithub.rest.repos.compareCommits = vi.fn(async () => {
+ if (status === "error") throw new Error("synthetic secret-bearing API diagnostic");
+ return { data: { status } };
+ });
+ const module = await loadModule();
+ const confirmed = status === "behind" || status === "identical";
+ const base = await module.resolveBundleTrustedBase(mockExec, { cwd: tempDir }, "old-head", "candidate", trustedTip, mockGithub, mockContext.repo, true);
+ expect(base).toBe(confirmed ? mergedBase : "");
+ expect(mockGithub.rest.repos.compareCommits).toHaveBeenCalledExactlyOnceWith({ ...mockContext.repo, base: trustedTip, head: mergedBase, per_page: 1, request: { timeout: 5000, retries: 0, signal: expect.any(AbortSignal) } });
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["rev-list", "--topo-order", "--max-count=101", mergedBase, "--not", "old-head"], expect.any(Object));
+ for (const [, , options] of mockExec.getExecOutput.mock.calls) {
+ expect(options).toEqual(expect.objectContaining({ cwd: tempDir, silent: true, env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "", GIT_NO_LAZY_FETCH: "1" }) }));
+ }
+ expect(JSON.stringify([mockCore.debug.mock.calls, mockCore.warning.mock.calls])).not.toContain("synthetic");
+ });
+
+ it.each(["no tip", "no merges", "already merged", "missing history"])("keeps PR-head validation without remote comparisons when there is %s", async scenario => {
+ mockExec.getExecOutput.mockImplementation(async (_cmd, args) => {
+ if (args[0] === "merge-base" || scenario === "missing history") return { exitCode: 128, stdout: "", stderr: "" };
+ return { exitCode: 0, stdout: scenario === "already merged" && args[1] === "--merges" ? `${"c".repeat(40)} ${mergedBase}\n` : "", stderr: "" };
+ });
+ mockGithub.rest.repos.compareCommits = vi.fn();
+ const module = await loadModule();
+ const base = await module.resolveBundleTrustedBase(mockExec, {}, "old-head", "candidate", scenario === "no tip" ? "" : trustedTip, mockGithub, mockContext.repo);
+ expect(base).toBe("");
+ expect(mockGithub.rest.repos.compareCommits).not.toHaveBeenCalled();
+ expect(await module.getBundleValidationBase(mockExec, {}, "old-head", "candidate", base)).toEqual({ ref: "old-head", conflictedFiles: [] });
+ });
+ });
+
// ──────────────────────────────────────────────────────
// Configuration Parsing Tests
// ──────────────────────────────────────────────────────
@@ -2330,7 +2376,7 @@ index 0000000..abc1234
describe("patch size validation", () => {
const protectedPatch = "diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1 +1 @@\n-old\n+new\n";
- it.each([
+ const scenarios = [
["small agent change", "small patch\n", 16, "merge-agent.txt", null, false],
["oversized agent patch", "x".repeat(2048), 16, "merge-agent.txt", "Patch size", false],
["oversized transient agent commit patch", "small patch\n", 16, "merge-agent.txt", "Patch size", false, "", false, false, "x".repeat(2048)],
@@ -2341,66 +2387,116 @@ index 0000000..abc1234
["reverted protected-file change after trusted-base fallback", "small patch\n", 16, "merge-agent.txt", "bundle modifies protected files", false, "package.json", false, true],
["agent-created octopus merge", "small patch\n", 16, "merge-agent.txt", "octopus merge", false, "", true],
["agent-created octopus merge after trusted-base fallback", "small patch\n", 16, "merge-agent.txt", "octopus merge", false, "", true, true],
- ])("validates %s independently of merged base changes", async (_name, agentPatch, contentSize, changedFile, expectedError, binaryConflict, historyFile = "", octopusMerge = false, mergeTreeFailure = false, historyPatch = agentPatch) => {
- const branch = "merge-size-validation";
- const trustedBase = "b".repeat(40);
- const validationTree = "a".repeat(40);
- createPatchFile(branch, protectedPatch + "x".repeat(4096));
- fs.writeFileSync(canonicalBundlePath(branch), "bundle content");
- const changedPath = path.join(process.cwd(), "merge-agent.txt");
- 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: "" };
- }
- if (args[0] === "merge-tree") {
- if (mergeTreeFailure) return { exitCode: 128, stdout: "", stderr: "unsupported merge-tree" };
- return { exitCode: binaryConflict ? 1 : 0, stdout: validationTree + (binaryConflict ? "\0merge-agent.txt\0\0" : "\0"), stderr: "" };
- }
- if (args[0] === "diff" && args[1] === "--binary") return { exitCode: 0, stdout: agentPatch, stderr: "" };
- if (args[0] === "diff" && args[1] === "--name-only") return { exitCode: 0, stdout: changedFile ? changedFile + "\0" : "", stderr: "" };
- 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: 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");
- try {
- const module = await loadModule();
- const handler = await module.main({ max_patch_size: 1, signed_commits: false, protected_files: ["package.json"] });
- const result = await handler({ branch, diff_size: 0 }, {});
- if (expectedError) {
- expect(result.success).toBe(false);
- expect(result.error).toContain(expectedError);
- expect(pushSpy).not.toHaveBeenCalled();
- } else {
- expect(result.success).toBe(true);
- 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.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));
- }
- if (expectedError === "octopus merge") {
- const historyExclusions = mergeTreeFailure ? ["remote-head"] : ["remote-head", trustedBase];
- expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["rev-list", "--min-parents=3", "refs/bundles/push-feature-branch", "--not", ...historyExclusions], expect.any(Object));
- }
- if (expectedError === "Changed content size") {
- expect(mockExec.exec).toHaveBeenCalledWith("git", ["reset", "--hard", "remote-head"], expect.any(Object));
+ ];
+ const cases = scenarios.flatMap(scenario => [false, true].map(baseAdvanced => [baseAdvanced, true, ...scenario]));
+ cases.push(
+ [true, false, "oversized agent patch with older-base discovery disabled", "x".repeat(2048), 16, "merge-agent.txt", "Patch size", false],
+ [true, false, "oversized expanded content with older-base discovery disabled", "small patch\n", 2048, "merge-agent.txt", "Changed content size", false]
+ );
+ it.each(cases)(
+ "validates with advanced base %s and merged-base opt-in %s: %s",
+ async (baseAdvanced, allowMergedBaseBaseline, _name, agentPatch, contentSize, changedFile, expectedError, binaryConflict, historyFile = "", octopusMerge = false, mergeTreeFailure = false, historyPatch = agentPatch) => {
+ const branch = "merge-size-validation";
+ const trustedBase = "b".repeat(40);
+ const trustedTip = baseAdvanced ? "d".repeat(40) : trustedBase;
+ const validationTree = "a".repeat(40);
+ createPatchFile(branch, protectedPatch + "x".repeat(4096));
+ fs.writeFileSync(canonicalBundlePath(branch), "bundle content");
+ const changedPath = path.join(process.cwd(), "merge-agent.txt");
+ fs.writeFileSync(changedPath, Buffer.alloc(contentSize));
+ const pr = (await mockGithub.rest.pulls.get()).data;
+ pr.base.sha = trustedBase;
+ pr.base.ref = "main";
+ mockGithub.rest.repos.compareCommits = vi.fn().mockResolvedValue({ data: { status: "behind" } });
+ mockExec.getExecOutput.mockImplementation(async (_cmd, args) => {
+ if (args[0] === "ls-remote" && args.includes("--refs")) return { exitCode: 0, stdout: `${trustedTip}\trefs/heads/main\n`, stderr: "" };
+ if (args[0] === "merge-base") {
+ if (baseAdvanced && args[2] === trustedTip) return { exitCode: 128, stdout: "", stderr: "tip object unavailable" };
+ return { exitCode: args[3] === "remote-head" ? 1 : 0, stdout: "", stderr: "" };
+ }
+ if (args[0] === "rev-list" && args[1] === "--merges") return { exitCode: 0, stdout: `${"c".repeat(40)} ${trustedBase}\n`, stderr: "" };
+ if (args[0] === "rev-list" && args[1] === "--topo-order") return { exitCode: 0, stdout: `${trustedBase}\n`, stderr: "" };
+ if (args[0] === "merge-tree") {
+ if (mergeTreeFailure) return { exitCode: 128, stdout: "", stderr: "unsupported merge-tree" };
+ return { exitCode: binaryConflict ? 1 : 0, stdout: validationTree + (binaryConflict ? "\0merge-agent.txt\0\0" : "\0"), stderr: "" };
+ }
+ if (args[0] === "diff" && args[1] === "--binary") return { exitCode: 0, stdout: agentPatch, stderr: "" };
+ if (args[0] === "diff" && args[1] === "--name-only") return { exitCode: 0, stdout: changedFile ? changedFile + "\0" : "", stderr: "" };
+ 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: 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");
+ try {
+ const module = await loadModule();
+ const handler = await module.main({
+ max_patch_size: 1,
+ signed_commits: false,
+ allow_merged_base_baseline: allowMergedBaseBaseline,
+ protected_files: ["package.json"],
+ });
+ const result = await handler({ branch, diff_size: 0 }, {});
+ if (expectedError) {
+ expect(result.success).toBe(false);
+ expect(result.error).toContain(expectedError);
+ if (baseAdvanced && !allowMergedBaseBaseline && ["Patch size", "Changed content size"].includes(expectedError)) {
+ expect(result.error).toContain("allow-merged-base-baseline: true");
+ }
+ expect(pushSpy).not.toHaveBeenCalled();
+ } else {
+ expect(result.success).toBe(true);
+ 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));
+ if (baseAdvanced) {
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["diff", "--name-only", "--no-renames", "-z", "remote-head..HEAD"], expect.any(Object));
+ }
+ }
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "--refs", "--exit-code", "origin", "refs/heads/main"], expect.objectContaining({ silent: true }));
+ if (baseAdvanced && allowMergedBaseBaseline) {
+ expect(mockGithub.rest.repos.compareCommits).toHaveBeenCalledWith({
+ owner: "test-owner",
+ repo: "test-repo",
+ base: trustedTip,
+ head: trustedBase,
+ per_page: 1,
+ request: { timeout: 5000, retries: 0, signal: expect.any(AbortSignal) },
+ });
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["merge-tree", "--write-tree", "--name-only", "-z", "remote-head", trustedBase], expect.any(Object));
+ } else {
+ expect(mockGithub.rest.repos.compareCommits).not.toHaveBeenCalled();
+ }
+ if (!baseAdvanced || allowMergedBaseBaseline) {
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["merge-tree", "--write-tree", "--name-only", "-z", "remote-head", trustedBase], expect.any(Object));
+ } else {
+ expect(mockExec.getExecOutput).not.toHaveBeenCalledWith("git", ["merge-tree", "--write-tree", "--name-only", "-z", "remote-head", trustedBase], expect.any(Object));
+ }
+ expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "origin", trustedBase], expect.any(Object));
+ if (!mergeTreeFailure && expectedError !== "protected files" && expectedError !== "Patch size") {
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["diff", "--name-only", "--no-renames", "-z", `${baseAdvanced ? "remote-head" : validationTree}..refs/bundles/push-feature-branch`], expect.any(Object));
+ }
+ if (expectedError === "bundle modifies protected files") {
+ const historyExclusions = mergeTreeFailure || baseAdvanced ? ["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)
+ );
+ }
+ if (expectedError === "octopus merge") {
+ const historyExclusions = mergeTreeFailure || baseAdvanced ? ["remote-head"] : ["remote-head", trustedBase];
+ expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["rev-list", "--min-parents=3", "refs/bundles/push-feature-branch", "--not", ...historyExclusions], expect.any(Object));
+ }
+ if (expectedError === "Changed content size") {
+ expect(mockExec.exec).toHaveBeenCalledWith("git", ["reset", "--hard", "remote-head"], expect.any(Object));
+ }
+ } finally {
+ pushSpy.mockRestore();
+ fs.rmSync(changedPath, { force: true });
}
- } finally {
- pushSpy.mockRestore();
- fs.rmSync(changedPath, { force: true });
}
- });
+ );
it.each([128, "throw"])("retains the PR-head baseline when merge-tree fails (%s)", async failure => {
mockExec.getExecOutput.mockImplementation(async (_cmd, args) => {
diff --git a/actions/setup/js/push_to_pull_request_branch_comparisons.test.cjs b/actions/setup/js/push_to_pull_request_branch_comparisons.test.cjs
new file mode 100644
index 00000000000..35c818680a8
--- /dev/null
+++ b/actions/setup/js/push_to_pull_request_branch_comparisons.test.cjs
@@ -0,0 +1,227 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+describe("bundle merge-parent comparison budget", () => {
+ const comparisonBudget = 4;
+ const trustedTip = "a".repeat(40);
+ const oldHead = "b".repeat(40);
+ const candidate = "c".repeat(40);
+ const repoParts = { owner: "test-owner", repo: "test-repo" };
+ const secretDiagnostic = "synthetic secret-bearing API diagnostic";
+ const request = { timeout: 5000, retries: 0, signal: expect.any(AbortSignal) };
+ const parents = Array.from({ length: 24 }, (_, index) => (index + 1).toString(16).padStart(40, "0"));
+ let mockCore;
+ let mockExec;
+ let compareCommits;
+ let resolveBundleTrustedBase;
+
+ beforeEach(() => {
+ mockCore = { debug: vi.fn(), warning: vi.fn() };
+ vi.stubGlobal("core", mockCore);
+ mockExec = {
+ async execGitSyncResult(args, options) {
+ const result = await mockExec.getExecOutput("git", args, { ...options, silent: true, ignoreReturnCode: true });
+ return { ...result, status: result.exitCode };
+ },
+ exec: vi.fn(),
+ getExecOutput: vi.fn(async (_command, args) => {
+ if (args[0] === "merge-base") return { exitCode: 128, stdout: "", stderr: secretDiagnostic };
+ if (args[1] === "--merges") {
+ const merges = parents.map((parent, index) => `${(index + 100).toString(16).padStart(40, "0")} ${oldHead} ${parent}`);
+ return { exitCode: 0, stdout: `${merges.join("\n")}\n`, stderr: "" };
+ }
+ if (args[1] === "--topo-order") return { exitCode: 0, stdout: `${parents.join("\n")}\n`, stderr: "" };
+ throw new Error("Unexpected git operation");
+ }),
+ };
+ compareCommits = vi.fn();
+ ({ resolveBundleTrustedBase } = require("./push_to_pull_request_branch.cjs"));
+ });
+
+ afterEach(() => {
+ expect(mockExec.exec).not.toHaveBeenCalled();
+ for (const [command, args, options] of mockExec.getExecOutput.mock.calls) {
+ expect(command).toBe("git");
+ expect(["merge-base", "rev-list"]).toContain(args[0]);
+ expect(options).toEqual(
+ expect.objectContaining({
+ cwd: process.cwd(),
+ silent: true,
+ ignoreReturnCode: true,
+ env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" }),
+ })
+ );
+ }
+ expect(JSON.stringify([mockCore.debug.mock.calls, mockCore.warning.mock.calls])).not.toContain(secretDiagnostic);
+ vi.unstubAllGlobals();
+ });
+
+ async function resolve() {
+ return resolveBundleTrustedBase(mockExec, { cwd: process.cwd(), env: { GIT_ALLOW_PROTOCOL: "https", GIT_NO_LAZY_FETCH: "0" } }, oldHead, candidate, trustedTip, { rest: { repos: { compareCommits } } }, repoParts, true);
+ }
+
+ it.each(["missing", "ahead", "diverged", "unknown"])("caps 24 attacker-controlled parents at four comparisons after %s results", async status => {
+ compareCommits.mockImplementation(async () => {
+ if (compareCommits.mock.calls.length > comparisonBudget) return { data: { status: "behind" } };
+ if (status === "missing") throw Object.assign(new Error(secretDiagnostic), { status: 404 });
+ return { data: { status } };
+ });
+
+ expect(await resolve()).toBe("");
+ expect(compareCommits).toHaveBeenCalledTimes(comparisonBudget);
+ expect(compareCommits.mock.calls).toEqual(parents.slice(0, comparisonBudget).map(head => [{ ...repoParts, base: trustedTip, head, per_page: 1, request }]));
+ expect(mockCore.debug).toHaveBeenCalledWith("Bundle validation: merge parent comparison budget exhausted");
+ });
+
+ it.each([401, 403, 429, 500, 502, 503, 504])("stops immediately on HTTP %s rather than querying another parent", async status => {
+ compareCommits.mockRejectedValueOnce(Object.assign(new Error(secretDiagnostic), { status })).mockResolvedValue({ data: { status: "behind" } });
+
+ expect(await resolve()).toBe("");
+ expect(compareCommits).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ["unknown error", new Error(secretDiagnostic)],
+ ["transport error", Object.assign(new Error(secretDiagnostic), { code: "ECONNRESET" })],
+ ["string error", secretDiagnostic],
+ ["null error", null],
+ ["undefined error", undefined],
+ ["unrecognized status", Object.assign(new Error(secretDiagnostic), { status: "404" })],
+ ])("stops immediately on %s", async (_label, error) => {
+ compareCommits.mockRejectedValueOnce(error).mockResolvedValue({ data: { status: "behind" } });
+
+ expect(await resolve()).toBe("");
+ expect(compareCommits).toHaveBeenCalledTimes(1);
+ });
+
+ it("aborts a stalled comparison after five seconds and stops discovery", async () => {
+ compareCommits.mockImplementation(
+ ({ request }) =>
+ new Promise((_resolve, reject) => {
+ request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true });
+ })
+ );
+ expect(await resolve()).toBe("");
+ expect(compareCommits).toHaveBeenCalledTimes(1);
+ expect(compareCommits.mock.calls[0][0].request.signal.aborted).toBe(true);
+ });
+
+ it.each(["behind", "identical"])("accepts a confirmed %s ancestor after missing parents within the budget", async status => {
+ compareCommits.mockImplementation(async () => {
+ if (compareCommits.mock.calls.length < comparisonBudget) throw Object.assign(new Error(secretDiagnostic), { status: 404 });
+ return { data: { status } };
+ });
+
+ expect(await resolve()).toBe(parents[comparisonBudget - 1]);
+ expect(compareCommits).toHaveBeenCalledTimes(comparisonBudget);
+ expect(compareCommits).toHaveBeenLastCalledWith({ ...repoParts, base: trustedTip, head: parents[comparisonBudget - 1], per_page: 1, request });
+ });
+
+ it.each(["merge history", "parent history", "octopus merge"])("falls back without API comparisons when %s exceeds the discovery budget", async scenario => {
+ const original = mockExec.getExecOutput.getMockImplementation();
+ mockExec.getExecOutput.mockImplementation(async (command, args, options) => {
+ if ((scenario === "merge history" && args[1] === "--merges") || (scenario === "parent history" && args[1] === "--topo-order")) {
+ return { exitCode: 0, stdout: Array.from({ length: 101 }, () => `${candidate} ${oldHead} ${parents[0]}`).join("\n"), stderr: "" };
+ }
+ if (scenario === "octopus merge" && args[1] === "--merges") {
+ return { exitCode: 0, stdout: `${candidate} ${oldHead} ${parents[0]} ${parents[1]}\n`, stderr: "" };
+ }
+ return original(command, args, options);
+ });
+
+ expect(await resolve()).toBe("");
+ expect(compareCommits).not.toHaveBeenCalled();
+ for (const [, args] of mockExec.getExecOutput.mock.calls) {
+ if (args[0] === "rev-list") expect(args).toContain("--max-count=101");
+ }
+ });
+
+ it.each(["merge history", "parent history"])("still recognizes an ancestor at the 100-record %s boundary", async scenario => {
+ const original = mockExec.getExecOutput.getMockImplementation();
+ mockExec.getExecOutput.mockImplementation(async (command, args, options) => {
+ if (scenario === "merge history" && args[1] === "--merges") {
+ return { exitCode: 0, stdout: Array.from({ length: 100 }, () => `${candidate} ${oldHead} ${parents[0]}`).join("\n"), stderr: "" };
+ }
+ if (scenario === "parent history" && args[1] === "--topo-order") {
+ return { exitCode: 0, stdout: [...Array.from({ length: 99 }, () => candidate), parents[0]].join("\n"), stderr: "" };
+ }
+ return original(command, args, options);
+ });
+ compareCommits.mockResolvedValue({ data: { status: "behind" } });
+ expect(await resolve()).toBe(parents[0]);
+ expect(compareCommits).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("bounded optional validation Git", () => {
+ const childProcess = require("child_process");
+ const gitHelpers = require("./git_helpers.cjs");
+ const { resolveTrustedBaseTip, resolveBundleTrustedBase, getBundleValidationBase } = require("./push_to_pull_request_branch.cjs");
+ let spawn;
+ let realSpawn;
+ let debug;
+
+ beforeEach(() => {
+ debug = vi.fn();
+ vi.stubGlobal("core", { debug, warning: vi.fn() });
+ realSpawn = childProcess.spawnSync;
+ spawn = vi.spyOn(childProcess, "spawnSync");
+ });
+
+ afterEach(() => {
+ spawn.mockRestore();
+ vi.unstubAllGlobals();
+ });
+
+ it("enforces process and output bounds that caller options cannot relax", async () => {
+ spawn.mockReturnValue({ status: 0, stdout: "git output", stderr: "private diagnostic" });
+ expect(await resolveTrustedBaseTip(gitHelpers, { cwd: "/checkout", env: { GIT_ALLOW_PROTOCOL: "" }, timeout: 0, maxBuffer: Infinity, shell: true }, "main")).toBe("");
+ expect(spawn).toHaveBeenCalledWith(
+ "git",
+ ["ls-remote", "--refs", "--exit-code", "origin", "refs/heads/main"],
+ expect.objectContaining({ cwd: "/checkout", env: expect.objectContaining({ GIT_ALLOW_PROTOCOL: "" }), timeout: 5000, maxBuffer: 1024 * 1024, killSignal: "SIGKILL", stdio: "pipe" })
+ );
+ expect(spawn.mock.calls.every(([, , options]) => options.shell !== true)).toBe(true);
+ expect(JSON.stringify(debug.mock.calls)).not.toContain("private");
+ });
+
+ it.each(["ETIMEDOUT", "ENOBUFS", "ENOENT", "SIGKILL", "SIGINT"])("discards incomplete output and falls back after %s", async failure => {
+ spawn.mockReturnValue({
+ status: failure.startsWith("SIG") ? null : 0,
+ signal: failure.startsWith("SIG") ? failure : null,
+ error: failure.startsWith("SIG") ? undefined : Object.assign(new Error("private diagnostic"), { code: failure }),
+ stdout: "partial private output",
+ stderr: "private diagnostic",
+ });
+ const compareCommits = vi.fn();
+ const client = { rest: { repos: { compareCommits } } };
+ expect(await resolveTrustedBaseTip(gitHelpers, {}, "main")).toBe("");
+ expect(await resolveBundleTrustedBase(gitHelpers, {}, "old", "candidate", "a".repeat(40), client, { owner: "owner", repo: "repo" }, true)).toBe("");
+ expect(await getBundleValidationBase(gitHelpers, {}, "old", "candidate", "a".repeat(40))).toEqual({ ref: "old", conflictedFiles: [] });
+ expect(compareCommits).not.toHaveBeenCalled();
+ expect(JSON.stringify(debug.mock.calls)).not.toContain("private");
+ });
+
+ it("preserves merge-tree conflict output for conservative file protection", async () => {
+ spawn
+ .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
+ .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
+ .mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
+ .mockReturnValue({ status: 1, stdout: `${"a".repeat(40)}\0package.json\0\0`, stderr: "private diagnostic" });
+ expect(await getBundleValidationBase(gitHelpers, {}, "old", "candidate", "base")).toEqual({ ref: "a".repeat(40), conflictedFiles: ["package.json"] });
+ expect(JSON.stringify(debug.mock.calls)).not.toContain("private");
+ });
+
+ it.each([
+ ["ETIMEDOUT", "setInterval(() => {}, 1000)"],
+ ["ENOBUFS", "process.stdout.write(Buffer.alloc(2 * 1024 * 1024)); setInterval(() => {}, 1000)"],
+ ])("terminates a real subprocess on %s without returning partial output", async (code, script) => {
+ let result;
+ spawn.mockImplementation((_command, _args, options) => {
+ result = realSpawn(process.execPath, ["-e", script], options);
+ return result;
+ });
+ expect(await resolveTrustedBaseTip(gitHelpers, {}, "main")).toBe("");
+ expect(result.error.code).toBe(code);
+ expect(result.signal).toBe("SIGKILL");
+ });
+});
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 c8c774d8a11..dd02a0f6371 100644
--- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md
+++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md
@@ -149,6 +149,8 @@ The agent's commits are packaged as a **git bundle** and uploaded as an Actions
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.
+For unsigned bundle pushes, the handler recognizes the exact pinned base tip by default. If the base branch has advanced past an ancestor already included in the bundle, that older-ancestor size baseline is opt-in: set `allow-merged-base-baseline: true` under `safe-outputs.push-to-pull-request-branch`. Without it, validation uses the PR-head baseline; a related patch-size error points to the opt-in. This setting only enables bounded metadata/REST discovery and does not fetch base objects or enable history recovery.
+
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.
:::caution[Shallow checkout and large monorepos]
@@ -437,9 +439,11 @@ By default, pushes are replayed through GitHub's signed commit API because `sign
Signed replay checks every commit's synthesized file changes before creating a remote branch or publishing commits. Each commit supports at most 4096 additions and deletions combined, subject to configured patch limits; exceeding this bound fails without unsigned fallback. Queue Claims retain per-path authorization, including the first commit's paths when creating a new branch.
-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.
+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 and changed-content-size checks can compare against a validation-only merge of the old PR head and a locally available ancestor confirmed against the pinned tip. Only the exact pinned tip exempts base changes from protected-file and history checks; other ancestors retain PR-head protection because reachability does not prove a historical base-branch state. 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.
+
+Optional baseline discovery limits each Git command to 5 seconds and 1 MiB per output stream, each history scan to 100 records, and REST comparisons to four requests with a 5-second abort deadline and no retries. Reaching a limit retains conservative PR-head validation. Only missing-parent (404) responses permit another comparison within that budget; other API failures stop discovery.
-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.
+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, deepen a shallow checkout, or populate missing blobs. A newer base tip may confirm an already-provisioned ancestor through metadata-only REST comparisons, but is never downloaded. 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
diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md
index 7aa0752ec76..08829255c90 100644
--- a/docs/src/content/docs/specs/safe-outputs-specification.md
+++ b/docs/src/content/docs/specs/safe-outputs-specification.md
@@ -7,9 +7,9 @@ sidebar:
# Safe Outputs MCP Gateway Specification
-**Version**: 1.29.12
+**Version**: 1.29.13
**Status**: Working Draft
-**Publication Date**: 2026-10-14
+**Publication Date**: 2026-10-18
**Editor**: GitHub Agentic Workflows Team
**This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/)
**Latest Published Version**: This document
@@ -2572,6 +2572,7 @@ safe-outputs:
12. **`head_repo` Required for Same-Organization Forks**: The owner-qualified head reference (requirement 9) alone is insufficient when `head-repo` and `target-repo` share the same owner/organization, because multiple repositories under that owner can define the same branch name. Whenever `head-repo` differs from `target-repo` — including when both share the same owner/organization — every pull request creation or update API call MUST include the `head_repo` parameter naming the head repository, in addition to the owner-qualified `head` reference.
13. **Signed Commit Delivery**: Branch delivery MUST satisfy the [signed commit delivery requirements](#signed-commit-delivery-requirements), shared with `push_to_pull_request_branch`.
14. **Transport Integrity**: Patch/bundle application and publication MUST satisfy the shared [transport integrity requirements](#patch-and-bundle-transport-integrity-requirements).
+15. **Bundle Git Operations**: Bundle inspection and prerequisite recovery MUST satisfy [BGO-001–003 and BGO-008](#bundle-git-operations-and-validation-baselines). Optional unsigned push baselines (BGO-004–007) apply only to `push_to_pull_request_branch`.
**Configuration Parameters**:
@@ -2591,6 +2592,7 @@ safe-outputs:
- `github-token`: Optional credential for upstream pull request creation and related upstream metadata operations
- `head-github-token`: Optional credential for `head-repo` branch writes. When specified, it SHOULD be limited to `contents: write` on `head-repo`
- `head-github-app`: Optional GitHub App configuration to mint an ephemeral credential for `head-repo` branch writes at runtime. When `head-github-app` is configured, the minted token takes precedence over `head-github-token`. The app installation MUST have `contents: write` on `head-repo`
+- `allow-full-history-fetch`: Literal boolean, default `false`, independently scoped to this handler. Permits potentially unbounded bundle prerequisite recovery; does not enable downloads for optional validation baselines.
- `preserve-branch-name`: When `true`, use the agent-supplied branch name verbatim without appending a random salt suffix (default: `false`)
- `recreate-ref`: When `true` (and `preserve-branch-name: true`), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD on collision. When `false` (default), an existing remote branch under `preserve-branch-name: true` causes a fallback rather than overwriting the remote ref. Has no effect when `preserve-branch-name: false`. (default: `false`)
@@ -2635,6 +2637,34 @@ These requirements apply to `create_pull_request` and `push_to_pull_request_bran
All implementation and test paths in this table are under `actions/setup/js/`. The bounded [transport lifecycle model](https://github.com/github/gh-aw/blob/main/specs/workflow-security/SafeOutputTransport.tla) specifies stable consumption, failure, retry, cleanup, and diagnostic invariants; it does not prove filesystem isolation or cryptographic collision resistance.
+##### Bundle Git Operations and Validation Baselines
+
+**BGO-001 — Offline object inspection**: Bundle prerequisite checks, local import, and optional baseline ancestry/tree inspection MUST deny all Git transports, including promisor lazy downloads, using both `GIT_ALLOW_PROTOCOL=""` and `GIT_NO_LAZY_FETCH=1`. Missing local history or blobs MUST NOT implicitly deepen a shallow checkout, expand sparse-checkout patterns, or materialize missing remote objects. A local bundle MAY supply its own objects without changing this rule.
+
+**BGO-002 — Explicit recovery authority**: `create_pull_request` and `push_to_pull_request_branch` MUST default `allow-full-history-fetch` to `false` and MUST accept only a literal boolean. Missing prerequisites MUST fail with checkout-provisioning guidance unless this handler's trusted configuration is exactly `true`. An agent request MUST NOT grant recovery authority. Opted-in recovery MAY use `--unshallow` or fetch-by-SHA and MUST warn that downloads have no byte limit. Depth, a SHA, and `blob:none` MUST NOT be described as byte bounds. A locally sufficient bundle MUST NOT trigger recovery.
+
+**BGO-003 — Credential roles**: Recovery MUST use the handler-selected authorized repository and its credentials. For `push_to_pull_request_branch`, this is the validated PR head repository, including a configured automation fork; head credentials MUST be restored before baseline discovery and validation. For `create_pull_request`, recovery uses the target checkout's `origin`. Optional base metadata discovery MUST query only the target checkout's `origin` and use its credentials, not a temporarily installed fork-head token. REST comparisons MUST use the upstream repository client; Git credentials MUST NOT be assumed to authorize REST.
+
+**BGO-004 — Pinned provenance and opt-in**: For unsigned bundle pushes, the handler MUST pin exactly one current base-branch SHA from an exact-ref, metadata-only `ls-remote` to preserve exact-tip recognition. It MUST NOT fetch the pinned object. Older-ancestor baseline discovery MUST be disabled by default and enabled only by literal `allow-merged-base-baseline: true` on `safe-outputs.push-to-pull-request-branch`. A locally proven merge of the exact pinned tip MUST bypass REST comparison discovery regardless of the opt-in. When older-ancestor discovery is enabled, a merge parent MAY be selected for size validation only when REST comparison with the pinned SHA as `base` and that parent as `head` reports `behind` or `identical`. Workflow-start SHAs, commit timestamps, and agent metadata MUST NOT substitute for pinned provenance. Parent preference MUST use topological ordering.
+
+**BGO-005 — Bounded discovery**: Optional base metadata, ancestry, and merge-tree commands MUST have a 5-second process timeout, a 1 MiB limit per output stream, noninteractive authentication, and no shell interpolation. Each merge-parent/history scan MUST accept at most 100 records, using a 101st record to detect overflow before trusting a partial scan. Octopus-parent discovery MUST fall back rather than expand an unbounded argument list. REST comparison discovery MUST issue at most four requests, each with a 5-second abort deadline and automatic retries disabled. Only candidate-specific missing-parent responses (404) MAY continue within the remaining budget. Authorization, rate-limit, service, transport, timeout, and unknown failures MUST stop comparison discovery immediately.
+
+**BGO-006 — Conservative fallback**: Timeouts, output overflow, missing required local objects, and unsupported merge-tree operations MUST NOT be treated as successful evidence; partial command output MUST NOT establish a baseline. An inconclusive exact-tip ancestry probe MAY proceed to independently bounded parent discovery without downloading the tip only when `allow-merged-base-baseline: true`. With older-ancestor discovery disabled, or when discovery is inconclusive, unavailable pinned metadata, history overflow, an unconfirmed ancestor, or inability to construct its validation tree MUST retain PR-head validation unless the exact pinned tip was locally proven. An optional baseline failure MUST NOT enable recovery downloads even when `allow-full-history-fetch` is true; that flag applies only to prerequisite recovery. Required file/history checks MUST NOT be skipped because optional discovery failed.
+
+**BGO-007 — Separate size and protection provenance**: A validation-only merge tree MAY exclude confirmed ancestor content from patch-size and changed-content-size calculations when the required objects and ancestry are available locally. Only the exact pinned base tip MAY exempt its history and unmodified files from protected-file checks. A non-tip ancestor MUST retain PR-head-based protected-file and history checks, including changes later reverted in side-branch or agent commits. Reachability alone does not prove a historical base-branch state. Agent changes and conflict resolutions MUST remain subject to policy checks; unvalidated octopus merge resolutions MUST be rejected.
+
+**BGO-008 — Safe failures and append-only delivery**: Optional discovery and recovery diagnostics MUST NOT expose raw remote errors, artifact contents, or credentials. Validation-only trees MUST NOT rebase or rewrite existing PR commits. Baseline selection MUST NOT change append-only push policy, signing defaults, or authorization of the destination repository.
+
+**Conformance Evidence**:
+
+| Requirements | Implementation / regression evidence |
+|---|---|
+| BGO-001–003 | `git_helpers.cjs`, `git_helpers.test.cjs`, and `bundle_transport.integration.test.cjs`: literal opt-in, scoped fork recovery, unchanged shallow markers and pack inventory, missing blobs still absent, and no optional fetches |
+| BGO-004–006 | `push_to_pull_request_branch.cjs` and `push_to_pull_request_branch_comparisons.test.cjs`: disabled-by-default discovery, exact-tip compatibility, 100/101-record boundaries, 24-parent request caps, abort signals, real subprocess time/output limits, fixed diagnostics, and conservative fallback |
+| BGO-007–008 | `push_to_pull_request_branch.test.cjs` and `push_to_pull_request_branch.integration.test.cjs`: exact-tip no-network path, advanced-base sizing, strict non-tip protection, A-add/B-remove side-branch imports, reverted changes, and unchanged PR refs on rejection |
+
+Paths in this table are under `actions/setup/js/`. The bounded [Git-operation model](https://github.com/github/gh-aw/blob/main/specs/workflow-security/SafeOutputGit.tla) separates prerequisite recovery from optional discovery and size trust from protection trust. Synthetic negative controls and reachability witnesses check the abstraction, not Git internals, wall-clock guarantees, credential grants, or implementation refinement.
+
**Security Requirements**:
- Branch name sanitization (prevent injection). Producers and consumers MUST reject dash-only branch names before deriving transport paths or publishing, including in allow-empty mode. Adding a salt MUST NOT turn an empty normalized branch into a valid branch name.
@@ -3689,6 +3719,7 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement
- `allowed-repos`: Cross-repository allowlist applied to both `target-repo` and `head-repo`
- `head-github-token`: Optional credential for `head-repo` branch writes
- `head-github-app`: Optional GitHub App configuration to mint an ephemeral credential for `head-repo` branch writes at runtime; takes precedence over `head-github-token` when both are specified
+- `allow-full-history-fetch`: Literal boolean, default `false`, independently scoped to this handler. Permits potentially unbounded bundle prerequisite recovery; does not enable downloads for optional validation baselines.
**Notes**:
@@ -3705,6 +3736,7 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement
- When the Convertible fallback (WTD2) opens a review or fallback pull request from `head-repo`, the `head_repo` parameter MUST be included on the pull request creation API call whenever `head-repo` differs from `target-repo`, including when both share the same owner/organization
- Signed branch delivery MUST satisfy the shared [signed commit delivery requirements](#signed-commit-delivery-requirements).
- Patch/bundle handling and publication MUST satisfy the shared [transport integrity requirements](#patch-and-bundle-transport-integrity-requirements).
+- Bundle inspection, recovery, and unsigned validation baselines MUST satisfy [BGO-001–008](#bundle-git-operations-and-validation-baselines).
---
@@ -6197,6 +6229,12 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries
- **Earlier changelog entry**: status comments were decoupled from default AI reaction behavior; explicit `on.status-comment` configuration is required when status comments are desired.
- **Earlier changelog entry**: `command` trigger was renamed to `slash_command` with deprecation compatibility.
+**Version 1.29.13** (2026-10-18):
+
+- **Added**: BGO-001–008 for offline bundle inspection, literal history-recovery opt-in, repository-specific credential roles, and bounded optional validation-baseline discovery.
+- **Clarified**: Confirmed non-tip ancestors may reduce size accounting but cannot exempt protected files/history; only the exact pinned base tip grants that exemption.
+- **Added**: A bounded Git-operation TLA+ model with shallow/full and opt-in/default-deny profiles, negative controls, and reachability witnesses in the pinned transport checker.
+
**Version 1.29.12** (2026-10-14):
- **Added**: PTI-001–007 for captured patch/bundle bytes, nonblocking regular-file checks, private snapshots, bounded integrity-check cost, trusted transformations, permanent retry classification, restricted Git cleanup, and safe diagnostics.
diff --git a/pkg/parser/schema_safe_outputs_target_test.go b/pkg/parser/schema_safe_outputs_target_test.go
index 6bc4ecfaad3..6537602491a 100644
--- a/pkg/parser/schema_safe_outputs_target_test.go
+++ b/pkg/parser/schema_safe_outputs_target_test.go
@@ -82,6 +82,48 @@ func TestMainWorkflowSchema_AllowFullHistoryFetch(t *testing.T) {
})
}
+func TestMainWorkflowSchema_AllowMergedBaseBaseline(t *testing.T) {
+ t.Parallel()
+ for _, tc := range []struct {
+ name string
+ value any
+ valid bool
+ }{
+ {"enabled", true, true},
+ {"disabled", false, true},
+ {"string", "true", false},
+ {"expression", "${{ inputs.allow-merged-base-baseline }}", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ frontmatter := map[string]any{
+ "on": "push",
+ "engine": "copilot",
+ "safe-outputs": map[string]any{
+ "push-to-pull-request-branch": map[string]any{"allow-merged-base-baseline": tc.value},
+ },
+ }
+ err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/merged-base-baseline-test.md")
+ if (err == nil) != tc.valid {
+ t.Fatalf("schema validity = %v, want %v: %v", err == nil, tc.valid, err)
+ }
+ })
+ }
+ t.Run("handler-scoped", 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-merged-base-baseline": true},
+ },
+ }
+ if err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/merged-base-baseline-test.md"); err == nil {
+ t.Fatal("expected merged-base baseline option on create-pull-request 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 b1d8b1b19f7..1ca11442e44 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -11257,6 +11257,11 @@
"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-merged-base-baseline": {
+ "type": "boolean",
+ "description": "Allow bounded REST comparison discovery of older merged base ancestors for unsigned bundle size validation. Disabled by default; the exact pinned base tip remains recognized.",
+ "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 95b83e925b0..ad7314bb2f4 100644
--- a/pkg/workflow/compiler_safe_outputs_config_targets_test.go
+++ b/pkg/workflow/compiler_safe_outputs_config_targets_test.go
@@ -74,6 +74,24 @@ func TestAllowFullHistoryFetchIsHandlerScoped(t *testing.T) {
assert.NotContains(t, handlerRegistry["push_to_pull_request_branch"](cfg), "allow_full_history_fetch")
}
+func TestAllowMergedBaseBaselineIsHandlerScoped(t *testing.T) {
+ t.Parallel()
+ cfg := NewCompiler().extractSafeOutputsConfig(map[string]any{"safe-outputs": map[string]any{
+ "create-pull-request": map[string]any{},
+ "push-to-pull-request-branch": map[string]any{"allow-merged-base-baseline": true},
+ }})
+ require.NotNil(t, cfg)
+ assert.NotContains(t, handlerRegistry["create_pull_request"](cfg), "allow_merged_base_baseline")
+ assert.Equal(t, true, handlerRegistry["push_to_pull_request_branch"](cfg)["allow_merged_base_baseline"])
+
+ 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))
+ assert.NotContains(t, runtime["create_pull_request"], "allow_merged_base_baseline")
+ assert.Equal(t, true, runtime["push_to_pull_request_branch"]["allow_merged_base_baseline"])
+}
+
// TestEmptySafeOutputsConfig tests behavior with no safe outputs
func TestEmptySafeOutputsConfig(t *testing.T) {
compiler := NewCompiler()
diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go
index a9d1da2e305..6e2224af248 100644
--- a/pkg/workflow/push_to_pull_request_branch.go
+++ b/pkg/workflow/push_to_pull_request_branch.go
@@ -36,6 +36,7 @@ type PushToPullRequestBranchConfig struct {
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).
+ AllowMergedBaseBaseline bool `yaml:"allow-merged-base-baseline,omitempty"` // Enables bounded discovery of older merged base ancestors for unsigned bundle size validation.
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.
}
@@ -89,168 +90,121 @@ func buildCheckoutRepository(steps []string, c *Compiler, targetRepoSlug string,
// parsePushToPullRequestBranchConfig handles push-to-pull-request-branch configuration
func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) *PushToPullRequestBranchConfig {
- if configData, exists := outputMap["push-to-pull-request-branch"]; exists {
- pushToPullRequestBranchLog.Print("Parsing push-to-pull-request-branch configuration")
- pushToBranchConfig := &PushToPullRequestBranchConfig{
- IfNoChanges: "warn", // Default behavior: warn when no changes
- }
-
- // Handle the case where configData is nil (push-to-pull-request-branch: with no value)
- if configData == nil {
- return pushToBranchConfig
- }
-
- if configMap, ok := configData.(map[string]any); ok {
- // Parse target (optional, similar to add-comment)
- if targetStr := extractTargetFromMap(configMap, pushToPullRequestBranchLog); targetStr != "" {
- pushToBranchConfig.Target = targetStr
- }
-
- // Parse if-no-changes (optional, defaults to "warn")
- if ifNoChanges, exists := configMap["if-no-changes"]; exists {
- if ifNoChangesStr, ok := ifNoChanges.(string); ok {
- // Validate the value
- switch ifNoChangesStr {
- case "warn", "error", "ignore":
- pushToBranchConfig.IfNoChanges = ifNoChangesStr
- default:
- // Invalid value, use default and log warning
- if c.verbose {
- fmt.Fprintf(os.Stderr, "Warning: invalid if-no-changes value '%s', using default 'warn'\n", ifNoChangesStr)
- }
- pushToBranchConfig.IfNoChanges = "warn"
- }
- }
- }
-
- // Parse ignore-missing-branch-failure (optional, defaults to false)
- if ignoreMissingBranchFailure, exists := configMap["ignore-missing-branch-failure"]; exists {
- if ignoreMissingBranchFailureBool, ok := ignoreMissingBranchFailure.(bool); ok {
- pushToBranchConfig.IgnoreMissingBranchFailure = ignoreMissingBranchFailureBool
- }
- }
-
- // Parse required-title-prefix (preferred) with fallback to deprecated title-prefix alias.
- pushToBranchConfig.TitlePrefix = extractStringFromMap(configMap, "required-title-prefix", pushToPullRequestBranchLog)
- if pushToBranchConfig.TitlePrefix == "" {
- pushToBranchConfig.TitlePrefix = extractStringFromMap(configMap, "title-prefix", pushToPullRequestBranchLog)
- }
-
- // Parse required-labels (preferred) with fallback to deprecated labels.
- pushToBranchConfig.RequiredLabels = ParseStringArrayOrExprFromConfig(configMap, "required-labels", pushToPullRequestBranchLog)
- if len(pushToBranchConfig.RequiredLabels) == 0 {
- pushToBranchConfig.RequiredLabels = ParseStringArrayOrExprFromConfig(configMap, "labels", pushToPullRequestBranchLog)
- }
-
- // Parse commit-title-suffix (optional)
- if commitTitleSuffix, exists := configMap["commit-title-suffix"]; exists {
- if commitTitleSuffixStr, ok := commitTitleSuffix.(string); ok {
- pushToBranchConfig.CommitTitleSuffix = commitTitleSuffixStr
- }
- }
-
- // Parse github-token-for-extra-empty-commit (optional) - token for pushing empty commit to trigger CI
- if emptyCommitToken, exists := configMap["github-token-for-extra-empty-commit"]; exists {
- if emptyCommitTokenStr, ok := emptyCommitToken.(string); ok {
- pushToBranchConfig.GithubTokenForExtraEmptyCommit = emptyCommitTokenStr
- pushToPullRequestBranchLog.Printf("Extra empty commit token configured")
- }
- }
-
- // Parse target-repo for cross-repository push
- pushToBranchConfig.TargetRepoSlug = extractStringFromMap(configMap, "target-repo", pushToPullRequestBranchLog)
- pushToBranchConfig.HeadRepoSlug = extractStringFromMap(configMap, "head-repo", pushToPullRequestBranchLog)
- pushToBranchConfig.HeadGitHubToken = extractStringFromMap(configMap, "head-github-token", pushToPullRequestBranchLog)
-
- // Parse head-github-app manually so that the app-id alias is honoured
- // (YAML unmarshal would silently ignore app-id since GitHubAppConfig only
- // declares the canonical client-id tag).
- if headAppData, exists := configMap["head-github-app"]; exists {
- if headAppMap, ok := headAppData.(map[string]any); ok {
- pushToPullRequestBranchLog.Print("Parsed head-github-app from config")
- pushToBranchConfig.HeadGitHubApp = parseAppConfig(headAppMap)
- }
- }
-
- // Parse base-branch for explicit override of the target repo's base branch.
- // When unset, the safe-outputs MCP server resolves it at runtime from the local
- // checkout metadata (origin/HEAD) or falls back to the repo's default branch.
- pushToBranchConfig.BaseBranch = extractStringFromMap(configMap, "base-branch", pushToPullRequestBranchLog)
-
- // Parse allowed-repos for cross-repository push (expression-aware)
- pushToBranchConfig.AllowedRepos = ParseStringArrayOrExprFromConfig(configMap, "allowed-repos", pushToPullRequestBranchLog)
-
- // Parse protected-files: supports string enum OR object form {policy, exclude}.
- exclude := preprocessProtectedFilesField(configMap, pushToPullRequestBranchLog)
- pushToBranchConfig.ProtectedFilesExclude = exclude
- // Validate policy string (no-op if the field was replaced by preprocessor)
- manifestFilesEnums := []string{"blocked", "allowed", "fallback-to-issue", "request_review", "request-review"}
- validateStringEnumField(configMap, "protected-files", manifestFilesEnums, pushToPullRequestBranchLog)
- if strVal, ok := configMap["protected-files"].(string); ok {
- normalised := normaliseProtectedFilesPolicy(strVal)
- pushToBranchConfig.ManifestFilesPolicy = &normalised
- }
-
- // Parse allowed-files: list of glob patterns forming a strict allowlist of eligible files
- pushToBranchConfig.AllowedFiles = ParseStringArrayFromConfig(configMap, "allowed-files", pushToPullRequestBranchLog)
-
- // Parse excluded-files: list of glob patterns for files to exclude via git :(exclude) pathspecs
- pushToBranchConfig.ExcludedFiles = ParseStringArrayFromConfig(configMap, "excluded-files", pushToPullRequestBranchLog)
-
- // Parse max-patch-size override (optional, must be > 0)
- if maxPatchSize, exists := configMap["max-patch-size"]; exists {
- if maxPatchSizeInt, ok := typeutil.ParseIntValue(maxPatchSize); ok && maxPatchSizeInt > 0 {
- pushToBranchConfig.MaxPatchSize = maxPatchSizeInt
- }
- }
-
- // Parse patch-format: valid values are "bundle" (default) and "am"
- patchFormatEnums := []string{"am", "bundle"}
- validateStringEnumField(configMap, "patch-format", patchFormatEnums, pushToPullRequestBranchLog)
- if patchFormat, exists := configMap["patch-format"]; exists {
- if patchFormatStr, ok := patchFormat.(string); ok {
- pushToBranchConfig.PatchFormat = patchFormatStr
- }
- }
+ configData, exists := outputMap["push-to-pull-request-branch"]
+ if !exists {
+ return nil
+ }
+ pushToPullRequestBranchLog.Print("Parsing push-to-pull-request-branch configuration")
+ pushToBranchConfig := &PushToPullRequestBranchConfig{IfNoChanges: "warn"}
+ if configData == nil {
+ return pushToBranchConfig
+ }
+ configMap, ok := configData.(map[string]any)
+ if !ok {
+ return pushToBranchConfig
+ }
- // Parse fallback-as-pull-request (optional, defaults to true)
- if fallbackAsPullRequest, exists := configMap["fallback-as-pull-request"]; exists {
- if fallbackAsPullRequestBool, ok := fallbackAsPullRequest.(bool); ok {
- pushToBranchConfig.FallbackAsPullRequest = &fallbackAsPullRequestBool
- }
- }
+ c.parsePushToPullRequestBranchConstraints(configMap, pushToBranchConfig)
+ c.parsePushToPullRequestBranchRepository(configMap, pushToBranchConfig)
+ c.parsePushToPullRequestBranchFilePolicies(configMap, pushToBranchConfig)
+ c.parsePushToPullRequestBranchTransport(configMap, pushToBranchConfig)
+ c.parsePushToPullRequestBranchOptions(configMap, pushToBranchConfig)
+ c.parseBaseSafeOutputConfig(configMap, &pushToBranchConfig.BaseSafeOutputConfig, 0)
+ return pushToBranchConfig
+}
- // Parse signed-commits (optional, defaults to true)
- if signedCommits, exists := configMap["signed-commits"]; exists {
- if signedCommitsBool, ok := signedCommits.(bool); ok {
- pushToBranchConfig.SignedCommits = &signedCommitsBool
+func (c *Compiler) parsePushToPullRequestBranchConstraints(configMap map[string]any, config *PushToPullRequestBranchConfig) {
+ if targetStr := extractTargetFromMap(configMap, pushToPullRequestBranchLog); targetStr != "" {
+ config.Target = targetStr
+ }
+ if ifNoChanges, exists := configMap["if-no-changes"]; exists {
+ if value, ok := ifNoChanges.(string); ok {
+ switch value {
+ case "warn", "error", "ignore":
+ config.IfNoChanges = value
+ default:
+ if c.verbose {
+ fmt.Fprintf(os.Stderr, "Warning: invalid if-no-changes value '%s', using default 'warn'\n", value)
}
}
+ }
+ }
+ if value, ok := configMap["ignore-missing-branch-failure"].(bool); ok {
+ config.IgnoreMissingBranchFailure = value
+ }
+ config.TitlePrefix = extractStringFromMap(configMap, "required-title-prefix", pushToPullRequestBranchLog)
+ if config.TitlePrefix == "" {
+ config.TitlePrefix = extractStringFromMap(configMap, "title-prefix", pushToPullRequestBranchLog)
+ }
+ config.RequiredLabels = ParseStringArrayOrExprFromConfig(configMap, "required-labels", pushToPullRequestBranchLog)
+ if len(config.RequiredLabels) == 0 {
+ config.RequiredLabels = ParseStringArrayOrExprFromConfig(configMap, "labels", pushToPullRequestBranchLog)
+ }
+}
- 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 {
- pushToBranchConfig.AllowWorkflows = allowWorkflowsBool
- }
- }
+func (c *Compiler) parsePushToPullRequestBranchRepository(configMap map[string]any, config *PushToPullRequestBranchConfig) {
+ if value, ok := configMap["commit-title-suffix"].(string); ok {
+ config.CommitTitleSuffix = value
+ }
+ if value, ok := configMap["github-token-for-extra-empty-commit"].(string); ok {
+ config.GithubTokenForExtraEmptyCommit = value
+ pushToPullRequestBranchLog.Printf("Extra empty commit token configured")
+ }
+ config.TargetRepoSlug = extractStringFromMap(configMap, "target-repo", pushToPullRequestBranchLog)
+ config.HeadRepoSlug = extractStringFromMap(configMap, "head-repo", pushToPullRequestBranchLog)
+ config.HeadGitHubToken = extractStringFromMap(configMap, "head-github-token", pushToPullRequestBranchLog)
+ if headAppData, exists := configMap["head-github-app"]; exists {
+ if headAppMap, ok := headAppData.(map[string]any); ok {
+ pushToPullRequestBranchLog.Print("Parsed head-github-app from config")
+ config.HeadGitHubApp = parseAppConfig(headAppMap)
+ }
+ }
+ config.BaseBranch = extractStringFromMap(configMap, "base-branch", pushToPullRequestBranchLog)
+ config.AllowedRepos = ParseStringArrayOrExprFromConfig(configMap, "allowed-repos", pushToPullRequestBranchLog)
+}
- // Parse check-branch-protection: when false, skips the branch protection API pre-flight check
- if checkBranchProtection, exists := configMap["check-branch-protection"]; exists {
- if checkBranchProtectionBool, ok := checkBranchProtection.(bool); ok {
- pushToBranchConfig.CheckBranchProtection = &checkBranchProtectionBool
- }
- }
+func (c *Compiler) parsePushToPullRequestBranchFilePolicies(configMap map[string]any, config *PushToPullRequestBranchConfig) {
+ config.ProtectedFilesExclude = preprocessProtectedFilesField(configMap, pushToPullRequestBranchLog)
+ manifestFilesEnums := []string{"blocked", "allowed", "fallback-to-issue", "request_review", "request-review"}
+ validateStringEnumField(configMap, "protected-files", manifestFilesEnums, pushToPullRequestBranchLog)
+ if value, ok := configMap["protected-files"].(string); ok {
+ normalised := normaliseProtectedFilesPolicy(value)
+ config.ManifestFilesPolicy = &normalised
+ }
+ config.AllowedFiles = ParseStringArrayFromConfig(configMap, "allowed-files", pushToPullRequestBranchLog)
+ config.ExcludedFiles = ParseStringArrayFromConfig(configMap, "excluded-files", pushToPullRequestBranchLog)
+}
- // Parse common base fields with default max of 0 (no limit)
- c.parseBaseSafeOutputConfig(configMap, &pushToBranchConfig.BaseSafeOutputConfig, 0)
+func (c *Compiler) parsePushToPullRequestBranchTransport(configMap map[string]any, config *PushToPullRequestBranchConfig) {
+ if maxPatchSize, exists := configMap["max-patch-size"]; exists {
+ if value, ok := typeutil.ParseIntValue(maxPatchSize); ok && value > 0 {
+ config.MaxPatchSize = value
}
-
- return pushToBranchConfig
}
+ patchFormatEnums := []string{"am", "bundle"}
+ validateStringEnumField(configMap, "patch-format", patchFormatEnums, pushToPullRequestBranchLog)
+ if value, ok := configMap["patch-format"].(string); ok {
+ config.PatchFormat = value
+ }
+}
- return nil
+func (c *Compiler) parsePushToPullRequestBranchOptions(configMap map[string]any, config *PushToPullRequestBranchConfig) {
+ if value, ok := configMap["fallback-as-pull-request"].(bool); ok {
+ config.FallbackAsPullRequest = &value
+ }
+ if value, ok := configMap["signed-commits"].(bool); ok {
+ config.SignedCommits = &value
+ }
+ if value, ok := configMap["allow-full-history-fetch"].(bool); ok {
+ config.AllowFullHistoryFetch = value
+ }
+ if value, ok := configMap["allow-merged-base-baseline"].(bool); ok {
+ config.AllowMergedBaseBaseline = value
+ }
+ if value, ok := configMap["allow-workflows"].(bool); ok {
+ config.AllowWorkflows = value
+ }
+ if value, ok := configMap["check-branch-protection"].(bool); ok {
+ config.CheckBranchProtection = &value
+ }
}
diff --git a/pkg/workflow/safe_outputs_handler_registry_pull_requests.go b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go
index f88ab41e766..85379482642 100644
--- a/pkg/workflow/safe_outputs_handler_registry_pull_requests.go
+++ b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go
@@ -26,6 +26,7 @@ var pullRequestHandlerRegistry = map[string]handlerBuilder{
AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix).
AddDefault("max_patch_size", maxPatchSize).
AddIfTrue("allow_full_history_fetch", c.AllowFullHistoryFetch).
+ AddIfTrue("allow_merged_base_baseline", c.AllowMergedBaseBaseline).
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
AddIfNotEmpty("head-repo", c.HeadRepoSlug).
AddIfNotEmpty("base_branch", c.BaseBranch).
diff --git a/specs/workflow-security/README.md b/specs/workflow-security/README.md
index 0cd78daf8d4..97379f163a1 100644
--- a/specs/workflow-security/README.md
+++ b/specs/workflow-security/README.md
@@ -45,6 +45,7 @@ sandboxed agent principal, even when GitHub Actions runs both in the same job.
| `pull_request_target` checkout policy | [`PullRequestTargetCheckout.tla`](PullRequestTargetCheckout.tla) | Strict warning acknowledgment, explicit checkout disablement, trusted base checkout, exact allowlisted external checkout, and fail-closed `fetch` handling. |
| PR checkout authorization | [`CommentTriggeredPRCheckout.tla`](CommentTriggeredPRCheckout.tla) | Same-repository Bot branch-event authorization for supported PR actions plus the stricter allow-listed comment/review exception. |
| Patch/bundle application | [`SafeOutputTransport.tla`](SafeOutputTransport.tla) | Captured bytes, trusted transformations, private snapshots, publication retries, sticky failures, bounded local checking cost, and finally cleanup. |
+| Bundle Git operations | [`SafeOutputGit.tla`](SafeOutputGit.tla) | Explicit prerequisite recovery, offline optional discovery, bounded comparison/history work, credential roles, and separate size/protection provenance. |
| Host-side MCP compilation | [`MCPCompile.tla`](MCPCompile.tla) | Startup write capability, actor authorization, early option rejection, filesystem isolation, cached-only imports, repository-root cache selection, validation, and write-enabled emission. |
The `issue` effect creates at most one validated issue in the main public
@@ -152,7 +153,8 @@ reported. An already completed remote effect is not rolled back if a later
completion check fails. Diagnostic safety and zero full-artifact hashes around
an unrelated local command are abstract obligations, not measurements of Node.
-Run both baselines, all negative controls, and all reachability witnesses with
+Run both transport baselines and the Git-operation profiles below, including
+all negative controls and reachability witnesses, with
the pinned TLA+ runtime described below. The runner rejects incorrect tooling
status or invariant names and automatically removes temporary configurations,
TLC states, and traces:
@@ -198,7 +200,86 @@ rollback remain implementation obligations covered by
`transport_integrity_git.test.cjs`, `error_recovery.test.cjs`, and
`create_or_update_pull_request.test.cjs` under `actions/setup/js/`.
The existing `check.py` and its checked-in examples cover the job and runtime
-repository-list checkout models; `check_transport.py` checks transport separately.
+repository-list checkout models; `check_transport.py` checks transport and Git
+operations separately. Use `--model transport` or `--model git` to select one.
+
+## Safe-output bundle Git operations
+
+[`SafeOutputGit.tla`](SafeOutputGit.tla) models the unsigned push-to-PR bundle
+path in Safe Outputs
+[BGO-001–008](../../docs/src/content/docs/specs/safe-outputs-specification.md#bundle-git-operations-and-validation-baselines).
+Prerequisite recovery and optional baseline discovery are distinct stages.
+Missing prerequisites fail by default; only trusted literal opt-in permits
+recovery with the validated head-repository credential. Metadata discovery uses
+the target checkout's credential instead, and never imports remote objects.
+The recovery flag never authorizes downloads during optional discovery.
+
+An exact pinned tip merged locally bypasses REST. Otherwise, bounded parent
+discovery compares against that pinned tip. Only `behind`/`identical` establishes
+an ancestor size baseline; `ahead` and candidate-specific 404 can continue
+within the request budget. Authorization, rate-limit, service, transport, timeout,
+and unknown failures stop comparison discovery. An inconclusive exact-tip
+ancestry probe can still enter bounded parent discovery without downloading
+the tip. Missing required local data, unsupported operations, time/output
+limits, history overflow, and budget exhaustion fall back to PR-head validation
+when the remaining checks cannot establish a baseline.
+A successful validation tree is required before an exemption is used. A
+non-tip ancestor never exempts protected history, including A-add/B-remove or
+reverted changes, even when it reduces size accounting. Exact-tip exemptions
+still do not excuse agent-authored protected changes.
+
+The runner exhausts all combinations of `AllowFullHistoryFetch` and `Shallow`.
+It requires exhaustive success for these four secure profiles, then the exact
+named counterexample for each synthetic fault:
+
+| Fault | Expected invariant |
+|---|---|
+| `implicit-recovery` | `RecoveryOptIn` |
+| `optional-fetch` / `lazy-fetch` | `NoOptionalMaterialization` |
+| `wrong-recovery-token` | `RecoveryAuthentication` |
+| `wrong-metadata-token` | `MetadataAuthentication` |
+| `unbounded-local` | `BoundedLocalInspection` |
+| `unbounded-history` | `BoundedHistory` |
+| `unbounded-comparisons` | `BoundedComparisons` |
+| `retry-global-error` | `NoGlobalFailureRetry` |
+| `comparison-retry` | `NoComparisonRetries` |
+| `unbounded-request` | `BoundedRequests` |
+| `ancestor-exemption` | `ProtectionProvenance` |
+| `unsafe-diagnostics` | `SafeDiagnostics` |
+
+The wrong-recovery-token control runs with opt-in enabled; the others use the
+default-deny shallow profile. Secure profiles additionally check shallow
+preservation, exact-tip zero-comparison behavior, conservative fallback, and
+protected-change rejection before publication. With `Fault = "none"`, the runner
+appends each of `NoAncestorBaseline`, `NoExactTipBaseline`, `NoRecovery`,
+`NoConservativeFallback`, `NoSafePublication`, `NoProtectedRejection`, and
+`NoMissingParentContinuation`. These false invariants witness effective ancestor
+and exact-tip baselines, successful opted-in recovery, fallback, valid
+publication, protected-history rejection, and a request following a 404,
+respectively. They prevent success from consisting only of rejected operations.
+
+```bash
+TLA2TOOLS_JAR=/path/to/tla2tools.jar JAVA_BIN=/path/to/java \
+ python3 specs/workflow-security/check_transport.py --model git
+```
+
+`SafeOutputGit.cfg` matches the implementation's four-comparison budget and
+100-record history limit. Finite boundary classes exercise both the 100th and
+101st records, and the over-budget fault attempts a fifth comparison.
+The implementation also uses a 5-second deadline per optional Git
+command/request and 1 MiB per process output stream.
+The model abstracts elapsed time/output into finite success, missing-data,
+timeout, and overflow outcomes. It checks decision boundaries, not real time,
+process termination, byte counts, Git parsing/DAG correctness, merge correctness,
+actual credential grants or restoration, signed delivery, ordinary PR-head
+fetch/push operations, or implementation refinement. Pinned refs, local ancestry
+answers, REST reachability responses, and protected-history classifications are
+assumed accurate inputs to the abstraction. The job-level model covers
+credential lifetime; runtime conformance for exact thresholds, subprocess
+termination, abort signals, and shallow/partial-clone preservation remains in
+`push_to_pull_request_branch_comparisons.test.cjs`,
+`push_to_pull_request_branch.integration.test.cjs`, and
+`bundle_transport.integration.test.cjs` under `actions/setup/js/`.
## Runtime repository-list checkout
@@ -345,6 +426,9 @@ parsing, symlink confinement, `.git` indirection, submodules, object graph
ancestry, merge correctness, signed pushes, or concurrent checkout discovery.
These require separate refinement of [`gitutil`](../../pkg/gitutil/gitutil.go)
and [`findRepoCheckout`](../../actions/setup/js/find_repo_checkout.cjs).
+The separate [bundle Git-operation model](#safe-output-bundle-git-operations)
+specifies privileged recovery and optional validation-baseline decisions without
+adding remote access to the credential-free agent's `GitOperation` transition.
## Host-side MCP compilation
diff --git a/specs/workflow-security/SafeOutputGit.cfg b/specs/workflow-security/SafeOutputGit.cfg
new file mode 100644
index 00000000000..30d37af5b87
--- /dev/null
+++ b/specs/workflow-security/SafeOutputGit.cfg
@@ -0,0 +1,26 @@
+SPECIFICATION Spec
+CONSTANTS
+ Fault = "none"
+ AllowFullHistoryFetch = FALSE
+ Shallow = TRUE
+ ComparisonBudget = 4
+ HistoryLimit = 100
+INVARIANTS
+ TypeOK
+ RecoveryOptIn
+ NoOptionalMaterialization
+ ShallowPreserved
+ RecoveryAuthentication
+ MetadataAuthentication
+ BoundedLocalInspection
+ BoundedHistory
+ BoundedComparisons
+ ExactTipFastPath
+ ConservativeFallback
+ NoGlobalFailureRetry
+ NoComparisonRetries
+ BoundedRequests
+ ProtectionProvenance
+ ProtectedPublication
+ SafeDiagnostics
+CHECK_DEADLOCK FALSE
diff --git a/specs/workflow-security/SafeOutputGit.tla b/specs/workflow-security/SafeOutputGit.tla
new file mode 100644
index 00000000000..94efbd2dc3e
--- /dev/null
+++ b/specs/workflow-security/SafeOutputGit.tla
@@ -0,0 +1,208 @@
+---------------------------- MODULE SafeOutputGit ----------------------------
+EXTENDS Naturals, Sequences
+
+CONSTANTS Fault, AllowFullHistoryFetch, Shallow, ComparisonBudget, HistoryLimit
+ASSUME AllowFullHistoryFetch \in BOOLEAN /\ Shallow \in BOOLEAN
+ASSUME ComparisonBudget \in Nat \ {0} /\ HistoryLimit \in Nat \ {0}
+ASSUME Fault \in {"none", "implicit-recovery", "optional-fetch", "lazy-fetch",
+ "wrong-recovery-token", "wrong-metadata-token",
+ "unbounded-local", "unbounded-history", "unbounded-comparisons",
+ "retry-global-error", "comparison-retry", "unbounded-request",
+ "ancestor-exemption", "unsafe-diagnostics"}
+
+\* Finite outcome classes abstract process deadlines and output limits.
+\* This is the push-to-PR bundle path, not an implementation of Git's DAG.
+VARIABLE s
+vars == <>
+Stages == {"prerequisites", "recover", "metadata", "inspect", "scan", "compare",
+ "tree", "protect", "publish", "done"}
+LocalOutcomes == {"ok", "missing", "unsupported", "timeout", "overflow"}
+CompareOutcomes == {"behind", "identical", "ahead", "missing",
+ "authorization", "rate-limit", "service", "transport",
+ "timeout", "unknown"}
+GlobalFailures == {"authorization", "rate-limit", "service", "transport",
+ "timeout", "unknown"}
+
+Init ==
+ \E missing, tipMerged, ancestorProtected, agentProtected \in BOOLEAN:
+ s = [stage |-> "prerequisites", missing |-> missing, tipMerged |-> tipMerged,
+ ancestorProtected |-> ancestorProtected, agentProtected |-> agentProtected,
+ shallow |-> Shallow, recovered |-> FALSE,
+ recoveryToken |-> "none", metadataToken |-> "none",
+ optionalDownloaded |-> FALSE, sparseExpanded |-> FALSE,
+ localBounded |-> TRUE, historyBounded |-> TRUE,
+ requestBounded |-> TRUE, retries |-> 0, comparisons |-> 0,
+ retriedGlobalFailure |-> FALSE, diagnosticsSafe |-> TRUE,
+ baseline |-> "pr-head", protectionExempt |-> FALSE,
+ fallback |-> FALSE, tipSeen |-> FALSE,
+ continuedMissing |-> FALSE, published |-> FALSE, rejected |-> FALSE,
+ lastEvent |-> "Init"]
+
+CheckPrerequisites ==
+ /\ s.stage = "prerequisites"
+ /\ s' = [s EXCEPT
+ !.stage = IF ~s.missing THEN "metadata"
+ ELSE IF AllowFullHistoryFetch \/ Fault = "implicit-recovery"
+ THEN "recover" ELSE "done",
+ !.rejected = s.missing /\ ~AllowFullHistoryFetch /\ Fault # "implicit-recovery",
+ !.lastEvent = "CheckPrerequisites"]
+
+Recover(success) ==
+ /\ s.stage = "recover"
+ /\ s' = [s EXCEPT
+ !.stage = IF success THEN "metadata" ELSE "done",
+ !.recoveryToken = IF Fault = "wrong-recovery-token" THEN "target" ELSE "head",
+ !.recovered = TRUE,
+ !.shallow = IF success THEN FALSE ELSE @,
+ !.rejected = ~success,
+ !.diagnosticsSafe = success \/ Fault # "unsafe-diagnostics",
+ !.lastEvent = IF success THEN "Recover" ELSE "RecoveryFailure"]
+
+Metadata(outcome) ==
+ /\ s.stage = "metadata"
+ /\ LET success == outcome = "ok"
+ IN s' = [s EXCEPT
+ !.stage = IF success THEN "inspect" ELSE "protect",
+ !.metadataToken = IF Fault = "wrong-metadata-token" THEN "head" ELSE "target",
+ !.fallback = ~success,
+ !.localBounded = ~(outcome \in {"timeout", "overflow"} /\ Fault = "unbounded-local"),
+ !.diagnosticsSafe = success \/ Fault # "unsafe-diagnostics",
+ !.lastEvent = IF success THEN "PinTargetMetadata" ELSE "MetadataFallback"]
+
+Inspect(outcome) ==
+ /\ s.stage = "inspect"
+ /\ LET success == outcome = "ok"
+ limited == outcome \in {"timeout", "overflow"}
+ download == outcome = "missing" /\ Fault \in {"optional-fetch", "lazy-fetch"}
+ IN s' = [s EXCEPT
+ !.stage = IF success THEN IF s.tipMerged THEN "tree" ELSE "scan"
+ ELSE "protect",
+ !.baseline = IF success /\ s.tipMerged THEN "tip" ELSE @,
+ !.tipSeen = success /\ s.tipMerged,
+ !.fallback = ~success,
+ !.localBounded = ~(limited /\ Fault = "unbounded-local"),
+ !.optionalDownloaded = download,
+ !.shallow = IF download THEN FALSE ELSE @,
+ !.sparseExpanded = download /\ Fault = "optional-fetch",
+ !.diagnosticsSafe = success \/ Fault # "unsafe-diagnostics",
+ !.lastEvent = "Inspect:" \o outcome]
+
+Scan(records, outcome) ==
+ /\ s.stage = "scan"
+ /\ LET within == records <= HistoryLimit
+ accept == outcome = "ok" /\ (within \/ Fault = "unbounded-history")
+ IN s' = [s EXCEPT
+ !.stage = IF accept THEN "compare" ELSE "protect",
+ !.historyBounded = ~accept \/ within,
+ !.localBounded = ~(outcome \in {"timeout", "overflow"} /\ Fault = "unbounded-local"),
+ !.fallback = ~accept,
+ !.diagnosticsSafe = outcome = "ok" \/ Fault # "unsafe-diagnostics",
+ !.lastEvent = IF accept THEN "Scan" ELSE "ScanFallback"]
+
+ComparisonLimit ==
+ /\ s.stage = "compare" /\ s.comparisons >= ComparisonBudget
+ /\ (Fault # "unbounded-comparisons" \/ s.comparisons > ComparisonBudget)
+ /\ s' = [s EXCEPT !.stage = "protect", !.fallback = TRUE,
+ !.lastEvent = "ComparisonBudgetFallback"]
+
+Compare(outcome) ==
+ /\ s.stage = "compare"
+ /\ (s.comparisons < ComparisonBudget \/
+ (Fault = "unbounded-comparisons" /\ s.comparisons = ComparisonBudget))
+ /\ LET confirmed == outcome \in {"behind", "identical"}
+ global == outcome \in GlobalFailures
+ retryGlobal == global /\ Fault = "retry-global-error"
+ stop == global /\ ~retryGlobal
+ IN s' = [s EXCEPT
+ !.comparisons = @ + 1,
+ !.stage = IF confirmed THEN "tree" ELSE IF stop THEN "protect" ELSE "compare",
+ !.baseline = IF confirmed THEN "ancestor" ELSE @,
+ !.fallback = stop,
+ !.continuedMissing = @ \/ s.lastEvent = "Compare:missing",
+ !.retriedGlobalFailure = @ \/ retryGlobal,
+ !.retries = IF Fault = "comparison-retry" THEN 1 ELSE 0,
+ !.requestBounded = ~(outcome = "timeout" /\ Fault = "unbounded-request"),
+ !.diagnosticsSafe = ~global \/ Fault # "unsafe-diagnostics",
+ !.lastEvent = "Compare:" \o outcome]
+
+BuildTree(outcome) ==
+ /\ s.stage = "tree"
+ /\ s' = [s EXCEPT
+ !.stage = "protect",
+ !.baseline = IF outcome = "ok" THEN @ ELSE "pr-head",
+ !.fallback = outcome # "ok",
+ !.localBounded = ~(outcome \in {"timeout", "overflow"} /\ Fault = "unbounded-local"),
+ !.diagnosticsSafe = outcome = "ok" \/ Fault # "unsafe-diagnostics",
+ !.lastEvent = IF outcome = "ok" THEN "ValidationTree" ELSE "TreeFallback"]
+
+ChooseProtection ==
+ /\ s.stage = "protect"
+ /\ s' = [s EXCEPT
+ !.stage = "publish",
+ !.protectionExempt = s.baseline = "tip" \/
+ (s.baseline = "ancestor" /\ Fault = "ancestor-exemption"),
+ !.lastEvent = "ChooseProtection"]
+
+Publish ==
+ /\ s.stage = "publish"
+ /\ LET denied == s.agentProtected \/ (s.ancestorProtected /\ ~s.protectionExempt)
+ IN s' = [s EXCEPT !.stage = "done", !.published = ~denied,
+ !.rejected = denied,
+ !.lastEvent = IF denied THEN "ProtectedRejection" ELSE "Publish"]
+
+Next ==
+ \/ CheckPrerequisites \/ ComparisonLimit \/ ChooseProtection \/ Publish
+ \/ \E success \in BOOLEAN: Recover(success)
+ \/ \E outcome \in LocalOutcomes:
+ Metadata(outcome) \/ Inspect(outcome) \/ BuildTree(outcome)
+ \/ \E records \in {HistoryLimit, HistoryLimit + 1}, outcome \in LocalOutcomes:
+ Scan(records, outcome)
+ \/ \E outcome \in CompareOutcomes: Compare(outcome)
+
+Spec == Init /\ [][Next]_vars
+
+TypeOK ==
+ /\ s.stage \in Stages
+ /\ s.missing \in BOOLEAN /\ s.tipMerged \in BOOLEAN
+ /\ s.ancestorProtected \in BOOLEAN /\ s.agentProtected \in BOOLEAN
+ /\ s.shallow \in BOOLEAN /\ s.recovered \in BOOLEAN
+ /\ s.recoveryToken \in {"none", "head", "target"}
+ /\ s.metadataToken \in {"none", "head", "target"}
+ /\ s.optionalDownloaded \in BOOLEAN /\ s.sparseExpanded \in BOOLEAN
+ /\ s.localBounded \in BOOLEAN /\ s.historyBounded \in BOOLEAN
+ /\ s.requestBounded \in BOOLEAN /\ s.retries \in 0..1
+ /\ s.comparisons \in 0..(ComparisonBudget + 1)
+ /\ s.retriedGlobalFailure \in BOOLEAN /\ s.diagnosticsSafe \in BOOLEAN
+ /\ s.baseline \in {"pr-head", "tip", "ancestor"}
+ /\ s.protectionExempt \in BOOLEAN /\ s.fallback \in BOOLEAN
+ /\ s.tipSeen \in BOOLEAN
+ /\ s.continuedMissing \in BOOLEAN
+ /\ s.published \in BOOLEAN /\ s.rejected \in BOOLEAN
+
+RecoveryOptIn == s.recovered => AllowFullHistoryFetch /\ s.missing
+ShallowPreserved == ~s.recovered => s.shallow = Shallow
+NoOptionalMaterialization == ~s.optionalDownloaded /\ ~s.sparseExpanded
+RecoveryAuthentication == s.recovered => s.recoveryToken = "head"
+MetadataAuthentication == s.metadataToken \in {"none", "target"}
+BoundedLocalInspection == s.localBounded
+BoundedHistory == s.historyBounded
+BoundedComparisons == s.comparisons <= ComparisonBudget
+ExactTipFastPath == s.tipSeen => s.comparisons = 0
+ConservativeFallback == s.fallback => s.baseline = "pr-head"
+NoGlobalFailureRetry == ~s.retriedGlobalFailure
+NoComparisonRetries == s.retries = 0
+BoundedRequests == s.requestBounded
+ProtectionProvenance == s.protectionExempt => s.baseline = "tip"
+ProtectedPublication ==
+ s.published => ~s.agentProtected /\ (~s.ancestorProtected \/ s.baseline = "tip")
+SafeDiagnostics == s.diagnosticsSafe
+
+\* Deliberately false reachability predicates; checked only with Fault = "none".
+NoAncestorBaseline == ~(s.stage = "publish" /\ s.baseline = "ancestor")
+NoExactTipBaseline == ~(s.stage = "publish" /\ s.baseline = "tip")
+NoRecovery == ~(s.recovered /\ s.stage = "metadata")
+NoConservativeFallback == ~s.fallback
+NoSafePublication == ~s.published
+NoProtectedRejection == ~(s.rejected /\ s.stage = "done" /\ ~s.missing)
+NoMissingParentContinuation == ~(s.continuedMissing /\ s.comparisons = 2)
+=============================================================================
diff --git a/specs/workflow-security/check_test.py b/specs/workflow-security/check_test.py
index c4c26634956..90692ccf1cc 100644
--- a/specs/workflow-security/check_test.py
+++ b/specs/workflow-security/check_test.py
@@ -31,10 +31,15 @@ def test_all_profiles_faults_and_witnesses_run_and_cleanup(self):
digest = hashlib.sha256(jar.read_bytes()).hexdigest()
configurations = []
destinations = []
+ modules = []
def run(command, cwd, **options):
configurations.append((cwd / "model.cfg").read_text())
destinations.append(cwd)
+ modules.append(command[-1])
+ self.assertEqual(options["timeout"], 120)
+ self.assertIn("-workers", command)
+ self.assertTrue((cwd / command[-1]).is_file())
return check_transport.subprocess.CompletedProcess(command, 0, "", "")
with patch.dict(os.environ, {"TLA2TOOLS_JAR": str(jar)}), \
@@ -45,14 +50,68 @@ def run(command, cwd, **options):
patch("builtins.print"):
check_transport.main()
expected = [None, *check_transport.FAULTS.values(), *check_transport.WITNESSES]
- self.assertEqual([call.args[2] for call in result.call_args_list], expected * 2)
+ git_cases = list(check_transport.git_cases())
+ self.assertEqual([call.args[2] for call in result.call_args_list],
+ expected * 2 + [case[-1] for case in git_cases])
for transport in ["patch", "bundle"]:
self.assertEqual(
sum(f'Transport = "{transport}"' in config for config in configurations),
len(expected),
)
+ git_configurations = configurations[len(expected) * 2:]
+ secure_profiles = {(allow, shallow) for name, allow, shallow, _, _ in git_cases
+ if name.startswith("secure-")}
+ self.assertEqual(secure_profiles, {(False, False), (False, True),
+ (True, False), (True, True)})
+ self.assertEqual(modules, ["SafeOutputTransport.tla"] * (len(expected) * 2)
+ + ["SafeOutputGit.tla"] * len(git_cases))
+ for config, (name, allow, shallow, fault, invariant) in zip(git_configurations,
+ git_cases):
+ self.assertIn(f'Fault = "{fault}"', config)
+ self.assertIn(f"AllowFullHistoryFetch = {str(allow).upper()}", config)
+ self.assertIn(f"Shallow = {str(shallow).upper()}", config)
+ self.assertIn("ComparisonBudget = 4", config)
+ self.assertIn("HistoryLimit = 100", config)
+ self.assertIn(" ProtectedPublication\n", config)
+ if name in check_transport.GIT_WITNESSES:
+ self.assertIn(f"\nINVARIANT {invariant}\n", config)
+ else:
+ self.assertNotIn("\nINVARIANT ", config)
self.assertTrue(all(not destination.exists() for destination in destinations))
+ def test_model_selection(self):
+ for model in ["transport", "git"]:
+ with self.subTest(model=model), tempfile.TemporaryDirectory() as directory:
+ jar = Path(directory) / "tla2tools.jar"
+ jar.write_bytes(b"local runtime fixture")
+ with patch.dict(os.environ, {"TLA2TOOLS_JAR": str(jar)}), \
+ patch.object(check_transport, "JAR_SHA256",
+ hashlib.sha256(jar.read_bytes()).hexdigest()), \
+ patch.object(check_transport, "run_case") as run:
+ check_transport.main(model)
+ expected = "SafeOutputGit" if model == "git" else "SafeOutputTransport"
+ self.assertGreater(run.call_count, 0)
+ self.assertTrue(all(call.args[2] == expected for call in run.call_args_list))
+
+ def test_git_tool_failure_is_surfaced_and_cleaned_up(self):
+ with tempfile.TemporaryDirectory() as directory:
+ jar = Path(directory) / "tla2tools.jar"
+ jar.write_bytes(b"local runtime fixture")
+ destinations = []
+
+ def run(command, cwd, **options):
+ destinations.append(cwd)
+ return subprocess.CompletedProcess(command, 1, "Error parsing module", "")
+
+ with patch.dict(os.environ, {"TLA2TOOLS_JAR": str(jar)}), \
+ patch.object(check_transport, "JAR_SHA256",
+ hashlib.sha256(jar.read_bytes()).hexdigest()), \
+ patch.object(check_transport.subprocess, "run", side_effect=run):
+ with self.assertRaisesRegex(RuntimeError, "git-secure.*TLC/tooling failure"):
+ check_transport.main("git")
+ self.assertEqual(len(destinations), 1)
+ self.assertFalse(destinations[0].exists())
+
class RuntimePinTest(unittest.TestCase):
def test_release_pin_matches_workflow_and_documentation(self):
diff --git a/specs/workflow-security/check_transport.py b/specs/workflow-security/check_transport.py
index 6ed0bbe5a28..af7d87b8385 100644
--- a/specs/workflow-security/check_transport.py
+++ b/specs/workflow-security/check_transport.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
-"""Check transport lifecycle profiles, negative controls, and reachability."""
+"""Check safe-output transport/Git profiles, controls, and reachability."""
+import argparse
import hashlib
import os
from pathlib import Path
@@ -27,47 +28,93 @@
"NoSuccessfulWrite", "NoRejectedMutation", "NoRateLimitRetry",
"NoCleanupFailure", "NoConflictCleanup",
]
+GIT_FAULTS = {
+ "implicit-recovery": "RecoveryOptIn",
+ "optional-fetch": "NoOptionalMaterialization",
+ "lazy-fetch": "NoOptionalMaterialization",
+ "wrong-recovery-token": "RecoveryAuthentication",
+ "wrong-metadata-token": "MetadataAuthentication",
+ "unbounded-local": "BoundedLocalInspection",
+ "unbounded-history": "BoundedHistory",
+ "unbounded-comparisons": "BoundedComparisons",
+ "retry-global-error": "NoGlobalFailureRetry",
+ "comparison-retry": "NoComparisonRetries",
+ "unbounded-request": "BoundedRequests",
+ "ancestor-exemption": "ProtectionProvenance",
+ "unsafe-diagnostics": "SafeDiagnostics",
+}
+GIT_WITNESSES = [
+ "NoAncestorBaseline", "NoExactTipBaseline", "NoRecovery",
+ "NoConservativeFallback", "NoSafePublication", "NoProtectedRejection",
+ "NoMissingParentContinuation",
+]
+
+
+def git_cases():
+ for allow in [False, True]:
+ for shallow in [False, True]:
+ yield (f"secure-recovery-{allow}-shallow-{shallow}", allow, shallow, "none", None)
+ for fault, invariant in GIT_FAULTS.items():
+ yield fault, fault == "wrong-recovery-token", True, fault, invariant
+ for invariant in GIT_WITNESSES:
+ yield invariant, invariant == "NoRecovery", True, "none", invariant
+
+
+def run_case(jar, temporary, module, name, config, invariant):
+ directory = Path(temporary) / name
+ directory.mkdir()
+ (directory / f"{module}.tla").write_text((ROOT / f"{module}.tla").read_text())
+ (directory / "model.cfg").write_text(config)
+ result = subprocess.run(
+ [os.environ.get("JAVA_BIN", "java"), "-XX:+UseParallelGC",
+ "-Xmx1g", "-cp", str(jar), "tlc2.TLC", "-workers", "1",
+ "-seed", "1", "-fp", "0", "-config", "model.cfg",
+ "-metadir", "states", f"{module}.tla"],
+ cwd=directory, capture_output=True, text=True, timeout=120,
+ )
+ try:
+ counts = check_result(result.returncode, result.stdout + result.stderr, invariant)
+ except RuntimeError as error:
+ raise RuntimeError(
+ f"{name}: {error}\n{result.stdout}{result.stderr}"
+ ) from error
+ print(f"{name}: expected result ({counts['distinct']} distinct states)", flush=True)
-def main():
+def main(model="all"):
jar_env = os.environ.get("TLA2TOOLS_JAR")
if not jar_env:
raise RuntimeError("Set TLA2TOOLS_JAR to the official v1.8.0 tla2tools.jar")
jar = Path(jar_env).resolve()
if not jar.is_file() or hashlib.sha256(jar.read_bytes()).hexdigest() != JAR_SHA256:
raise RuntimeError("TLA2TOOLS_JAR is missing or does not match the pinned SHA-256")
- model = (ROOT / "SafeOutputTransport.tla").read_text()
template = (ROOT / "SafeOutputTransport.cfg").read_text()
cases = [("secure", "none", None)]
cases += [(fault, fault, invariant) for fault, invariant in FAULTS.items()]
cases += [(name, "none", name) for name in WITNESSES]
with tempfile.TemporaryDirectory(prefix="gh-aw-transport-tlc-") as temporary:
- for transport in ["patch", "bundle"]:
- for name, fault, invariant in cases:
- directory = Path(temporary) / f"{transport}-{name}"
- directory.mkdir()
- (directory / "SafeOutputTransport.tla").write_text(model)
- config = template.replace('Transport = "patch"', f'Transport = "{transport}"')
- config = config.replace('Fault = "none"', f'Fault = "{fault}"')
- if name in WITNESSES:
+ if model in {"all", "transport"}:
+ for transport in ["patch", "bundle"]:
+ for name, fault, invariant in cases:
+ config = template.replace('Transport = "patch"', f'Transport = "{transport}"')
+ config = config.replace('Fault = "none"', f'Fault = "{fault}"')
+ if name in WITNESSES:
+ config += f"\nINVARIANT {name}\n"
+ run_case(jar, temporary, "SafeOutputTransport", f"{transport}-{name}",
+ config, invariant)
+ if model in {"all", "git"}:
+ template = (ROOT / "SafeOutputGit.cfg").read_text()
+ for name, allow, shallow, fault, invariant in git_cases():
+ config = template.replace('Fault = "none"', f'Fault = "{fault}"')
+ config = config.replace("AllowFullHistoryFetch = FALSE",
+ f"AllowFullHistoryFetch = {str(allow).upper()}")
+ config = config.replace("Shallow = TRUE", f"Shallow = {str(shallow).upper()}")
+ if name in GIT_WITNESSES:
config += f"\nINVARIANT {name}\n"
- (directory / "model.cfg").write_text(config)
- result = subprocess.run(
- [os.environ.get("JAVA_BIN", "java"), "-XX:+UseParallelGC",
- "-Xmx1g", "-cp", str(jar), "tlc2.TLC", "-workers", "1",
- "-seed", "1", "-fp", "0", "-config", "model.cfg",
- "-metadir", "states", "SafeOutputTransport.tla"],
- cwd=directory, capture_output=True, text=True, timeout=120,
- )
- try:
- counts = check_result(result.returncode, result.stdout + result.stderr, invariant)
- except RuntimeError as error:
- raise RuntimeError(
- f"{transport}/{name}: {error}\n{result.stdout}{result.stderr}"
- ) from error
- print(f"{transport}/{name}: expected result "
- f"({counts['distinct']} distinct states)", flush=True)
+ run_case(jar, temporary, "SafeOutputGit", f"git-{name}", config, invariant)
if __name__ == "__main__":
- main()
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--model", choices=["all", "transport", "git"], default="all")
+ main(parser.parse_args().model)