From 73bde327aef535c022115e54a128bbb74aea8637 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:27:55 +0000 Subject: [PATCH 1/5] Initial plan From 21269913edb2b22f358f8ece061734c65515354b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:47:36 +0000 Subject: [PATCH 2/5] Preserve state push errors and authenticate conflict retries Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_experiment_state.cjs | 9 ++-- .../setup/js/push_experiment_state.test.cjs | 42 +++++++++++++++ actions/setup/js/push_repo_memory.test.cjs | 7 ++- actions/setup/js/push_signed_commits.cjs | 12 ++++- actions/setup/js/push_signed_commits.test.cjs | 38 +++++++++++++ actions/setup/js/report_failed_jobs.cjs | 54 +++++++++++++++---- actions/setup/js/report_failed_jobs.test.cjs | 44 +++++++++++++++ 7 files changed, 190 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/push_experiment_state.cjs b/actions/setup/js/push_experiment_state.cjs index 9b4292a893a..9df89741f6b 100644 --- a/actions/setup/js/push_experiment_state.cjs +++ b/actions/setup/js/push_experiment_state.cjs @@ -560,15 +560,16 @@ async function main() { // Refresh baseRef and fetch the updated remote history so that // pushSignedCommits can resolve the new baseRef in git rev-list. try { - const { stdout: lsOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { cwd: workspaceDir }); + const gitEnv = { ...process.env, ...getGitAuthEnv(ghToken) }; + const { stdout: lsOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { cwd: workspaceDir, env: gitEnv }); const remoteHead = lsOut.trim().split(/\s+/)[0] || ""; if (remoteHead && remoteHead !== currentBaseRef) { - currentBaseRef = remoteHead; - core.info(`Refreshed baseRef for retry: ${currentBaseRef}`); // Fetch the updated branch history into the local repo so pushSignedCommits // can resolve currentBaseRef in `git rev-list baseRef..HEAD`. try { - execGitSync(["fetch", "origin", `refs/heads/${branchName}`], { stdio: "pipe", cwd: workspaceDir, suppressLogs: true }); + execGitSync(["fetch", "origin", `refs/heads/${branchName}`], { stdio: "pipe", cwd: workspaceDir, env: gitEnv, suppressLogs: true }); + currentBaseRef = remoteHead; + core.info(`Refreshed baseRef for retry: ${currentBaseRef}`); } catch (fetchErr) { core.info(`Fetch of branch "${branchName}" on retry failed (non-fatal): ${getErrorMessage(fetchErr)}`); } diff --git a/actions/setup/js/push_experiment_state.test.cjs b/actions/setup/js/push_experiment_state.test.cjs index 1f876a06784..37aed21f048 100644 --- a/actions/setup/js/push_experiment_state.test.cjs +++ b/actions/setup/js/push_experiment_state.test.cjs @@ -3,6 +3,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import os from "os"; import path from "path"; import fs from "fs"; +import vm from "node:vm"; +import { createRequire } from "node:module"; // Globals required by push_experiment_state.cjs and its dependencies const mockCore = { @@ -128,6 +130,46 @@ describe("push_experiment_state", () => { statSpy.mockRestore(); }); + it.each([false, true])("authenticates retry fetches and advances the base only after a successful fetch (fetch fails: %s)", async fetchFails => { + const baseRef = "a".repeat(40); + const remoteHead = "b".repeat(40); + const gitEnv = { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_0: "masked-auth-header" }; + const execGitSync = vi.fn(args => { + if (args[0] === "status") return "M state.json"; + if (args[0] === "rev-parse") return baseRef; + if (fetchFails && args[0] === "fetch" && args[1] === "origin") throw new Error("temporary fetch failure"); + return ""; + }); + const pushSignedCommits = vi.fn().mockRejectedValueOnce(new Error("non-fast-forward")).mockResolvedValue(undefined); + const exec = { getExecOutput: vi.fn().mockResolvedValue({ stdout: `${remoteHead}\trefs/heads/evals/test` }) }; + const require = createRequire(import.meta.url); + const module = { exports: {} }; + const workspaceDir = path.join(tmpDir, "workspace"); + fs.mkdirSync(workspaceDir); + fs.writeFileSync(path.join(tmpDir, "state.json"), "{}"); + const mocks = { + "./git_helpers.cjs": { execGitSync, withGitRetry: async fn => fn() }, + "./git_auth_helpers.cjs": { getGitAuthEnv: () => gitEnv }, + "./push_signed_commits.cjs": { pushSignedCommits }, + }; + vm.runInNewContext(fs.readFileSync(path.join(import.meta.dirname, "push_experiment_state.cjs"), "utf8"), { + require: id => mocks[id] || require(id), + module, + core: mockCore, + exec, + context: mockContext, + github: {}, + setTimeout: fn => fn(), + process: { env: { GH_AW_STATE_BRANCH: "evals/test", GH_AW_STATE_DIR: tmpDir, GITHUB_WORKSPACE: workspaceDir, GH_TOKEN: "test-token" } }, + }); + await module.exports.main(); + expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "origin", "refs/heads/evals/test"], { cwd: workspaceDir, env: expect.objectContaining(gitEnv) }); + expect(execGitSync).toHaveBeenCalledWith(["fetch", "origin", "refs/heads/evals/test"], expect.objectContaining({ env: expect.objectContaining(gitEnv) })); + expect(pushSignedCommits).toHaveBeenCalledTimes(2); + expect(pushSignedCommits.mock.calls[1][0].baseRef).toBe(fetchFails ? baseRef : remoteHead); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + it("merges concurrent experiment state updates without losing same-variant increments", () => { const baseState = { counts: { prompt_style: { concise: 1, detailed: 1 } }, diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index 014ea152947..6e9e2b62ac0 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -2283,7 +2283,7 @@ describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegatio } }); - it("retries a compare-and-swap loss by rebasing onto the refreshed head and preserving both JSONL rows", async () => { + it("retries a non-fast-forward push by rebasing onto the refreshed head and preserving both JSONL rows", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "repo-memory-race-")); const remoteDir = path.join(rootDir, "remote.git"); const seedDir = path.join(rootDir, "seed"); @@ -2318,13 +2318,14 @@ describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegatio const pushSignedCommitsFn = vi.fn(async ({ baseRef: receivedBaseRef, cwd }) => { pushBases.push(receivedBaseRef); if (pushBases.length === 1) { - throw new Error("ERR_API: GraphQL createCommitOnBranch expectedHeadOid did not match"); + execFileSync("git", ["push", "origin", branchName], { cwd, stdio: "pipe" }); } const revList = execSync(`git rev-list --parents ${remoteHead}..HEAD`, { cwd, encoding: "utf8" }).trim(); const rows = fs.readFileSync(path.join(cwd, "history.jsonl"), "utf8").trim().split("\n"); expect(revList.split(/\s+/)).toHaveLength(2); expect(rows).toEqual(['{"id":"base"}', '{"id":"remote"}', '{"id":"local"}']); + execFileSync("git", ["push", "origin", branchName], { cwd, stdio: "pipe" }); }); await pushRepoMemoryChangesWithRetry({ @@ -2352,6 +2353,8 @@ describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegatio expect(pushBases).toEqual([baseRef, remoteHead]); expect(delays).toEqual([501]); expect(global.core.setFailed).not.toHaveBeenCalled(); + expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("fetch first")); + expect(execFileSync("git", ["--git-dir", remoteDir, "show", `${branchName}:history.jsonl`], { encoding: "utf8" })).toBe('{"id":"base"}\n{"id":"remote"}\n{"id":"local"}\n'); } finally { randomSpy.mockRestore(); delete global.core; diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 4cca7cc0571..19051ea1638 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -13,6 +13,7 @@ const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { backfillCommitObjects } = require("./git_helpers.cjs"); const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { redactSecrets, redactBuiltInPatterns } = require("./redact_secrets.cjs"); const ERROR_CODE_PREFIX_RE = /^(?:ERR_[A-Z_]+|E\d{3}):\s*/; /** * Strip the single leading `ERR_*: ` or `E001: ` sentinel from a message. @@ -271,10 +272,19 @@ async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl const pushArgs = pushRemoteUrl ? ["push", pushRemoteUrl, branch] : ["push", "origin", branch]; const pushOnce = async () => { await require("./work_queue_git_effects.cjs").assertGitPushAuthorized({ remote: pushRemoteUrl || "origin", branch, cwd, gitAuthEnv }); - await exec.exec("git", pushArgs, { + const result = await exec.getExecOutput("git", pushArgs, { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, + ignoreReturnCode: true, }); + if (result.exitCode !== 0) { + const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim(); + const secrets = [pushToken, process.env.GH_TOKEN, process.env.GITHUB_TOKEN, process.env.GITHUB_APP_TOKEN].filter(Boolean); + const detail = redactBuiltInPatterns(redactSecrets(output, secrets).content) + .content.replace(/(https?:\/\/)[^/\s@]+@/gi, "$1[REDACTED]@") + .replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, "$1[REDACTED]"); + throw new Error(`${ERR_SYSTEM}: git push failed (exit code ${result.exitCode}): ${detail}`); + } return resolveLocalHeadSha(cwd); }; if (!pushRemoteUrl || !pushToken) { diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 74d6dc1c8c7..1c07cb36c31 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -978,6 +978,44 @@ describe("push_signed_commits integration tests", () => { // ────────────────────────────────────────────────────── describe("git push fallback when GraphQL fails", () => { + it("preserves git stderr when the exec wrapper only reports the exit code", async () => { + const realExec = global.exec; + global.exec = { + ...realExec, + getExecOutput: vi.fn().mockResolvedValue({ + exitCode: 1, + stdout: "", + stderr: "remote: error: GH013: Repository rule violations\n! [rejected] memory/test -> memory/test (non-fast-forward)", + }), + }; + try { + await expect(pushSignedCommits({ githubClient: {}, owner: "owner", repo: "repo", branch: "memory/test", baseRef: "", cwd: workDir, signedCommits: false })).rejects.toThrow("GH013: Repository rule violations"); + } finally { + global.exec = realExec; + } + }); + + it("redacts credentials from captured git push errors", async () => { + const realExec = global.exec; + vi.stubEnv("GH_TOKEN", "test-push-secret"); + global.exec = { + ...realExec, + getExecOutput: vi.fn().mockResolvedValue({ + exitCode: 128, + stdout: "", + stderr: `fatal: https://${["user", "password"].join(":")}@example.com/repo rejected test-push-secret\nAuthorization: Basic encoded-value`, + }), + }; + try { + const error = await pushSignedCommits({ githubClient: {}, owner: "owner", repo: "repo", branch: "memory/test", baseRef: "", cwd: workDir, signedCommits: false }).catch(error => error); + expect(error.message).toContain("fatal:"); + expect(error.message).not.toMatch(/test-push-secret|password|encoded-value/); + } finally { + global.exec = realExec; + vi.unstubAllEnvs(); + } + }); + it("should fall back to git push when GraphQL throws", async () => { execGit(["checkout", "-b", "fallback-branch"], { cwd: workDir }); fs.writeFileSync(path.join(workDir, "fallback.txt"), "Fallback content\n"); diff --git a/actions/setup/js/report_failed_jobs.cjs b/actions/setup/js/report_failed_jobs.cjs index 905e05832fb..d806c8b841d 100644 --- a/actions/setup/js/report_failed_jobs.cjs +++ b/actions/setup/js/report_failed_jobs.cjs @@ -9,9 +9,31 @@ const { generateFooterWithExpiration, createExpirationLine } = require("./epheme const { generateXMLMarker } = require("./messages.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { redactBuiltInPatterns } = require("./redact_secrets.cjs"); const GITHUB_API_VERSION = "2022-11-28"; const FAILED_JOBS_ISSUE_EXPIRES_HOURS = 24 * 7; // 1 week +const STATE_PUSH_JOB_IDS = new Set(["push_ledger_changes", "push_repo_memory", "push_evals_state", "push_experiment_state"]); + +/** + * Read only error lines from an already-masked state-push job log. + * @param {number} jobId + * @returns {Promise} + */ +async function getStatePushError(jobId) { + try { + const response = await github.rest.actions.downloadJobLogsForWorkflowRun({ ...context.repo, job_id: jobId }); + const log = typeof response.data === "string" ? response.data : Buffer.from(response.data).toString("utf8"); + const errors = log.split(/\r?\n/).filter(line => /##\[error\]|fatal:|error:|remote: error:|!\s+\[rejected\]/i.test(line)); + const detail = redactBuiltInPatterns(errors.slice(-20).join("\n")) + .content.replace(/(https?:\/\/)[^/\s@]+@/gi, "$1[REDACTED]@") + .replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, "$1[REDACTED]"); + return sanitizeContent(detail, 4000).replace(/```/g, "``\u200b`"); + } catch { + core.warning(`Could not retrieve error details for state-push job ${jobId}; see the job log`); + return ""; + } +} /** * Stable builtin job IDs whose failures are already reported @@ -45,18 +67,24 @@ function isActionsReadPermissionError(error) { /** * Format the list of failed jobs as a markdown bulleted list. - * @param {Array<{name: string, html_url: string | null}>} jobs + * @param {Array<{name: string, html_url: string | null, error_detail?: string}>} jobs * @returns {string} */ function formatFailedJobsList(jobs) { return jobs .map(job => { const safeName = sanitizeContent(job.name); + const detail = job.error_detail + ? `\n\n \`\`\`text\n${job.error_detail + .split("\n") + .map(line => ` ${line}`) + .join("\n")}\n \`\`\`\n` + : ""; if (job.html_url && job.html_url.startsWith("https://")) { const safeUrl = sanitizeContent(job.html_url); - return `- [\`${safeName}\`](${safeUrl})`; + return `- [\`${safeName}\`](${safeUrl})${detail}`; } - return `- \`${safeName}\``; + return `- \`${safeName}\`${detail}`; }) .join("\n"); } @@ -92,7 +120,7 @@ async function getFailedNonBuiltinJobs() { core.info(`Querying jobs for workflow run ${runId}`); - /** @type {Array<{name: string, html_url: string | null}>} */ + /** @type {Array<{name: string, html_url: string | null, id?: number, error_detail?: string}>} */ const runJobs = []; let page = 1; @@ -115,7 +143,7 @@ async function getFailedNonBuiltinJobs() { if (job.conclusion !== "failure") { continue; } - runJobs.push({ name: job.name, html_url: job.html_url }); + runJobs.push({ name: job.name, html_url: job.html_url, ...(job.id ? { id: job.id } : {}) }); } if (jobs.length < perPage) { @@ -126,14 +154,22 @@ async function getFailedNonBuiltinJobs() { const matchesName = (name, display) => name === display || name.startsWith(`${display} (`) || name.startsWith(`${display} / `); const knownIds = new Set([...Object.keys(jobResults), ...Object.keys(jobDisplayNames)]); - return failedJobIds.flatMap(jobId => { + const failedJobs = []; + for (const jobId of failedJobIds) { const displayName = jobDisplayNames[jobId] || jobId; const matchingJobs = runJobs.filter(job => matchesName(job.name, displayName)); const ambiguousName = [...knownIds].some(otherId => otherId !== jobId && matchingJobs.some(job => matchesName(job.name, jobDisplayNames[otherId] || otherId))); - if (matchingJobs.length > 0 && !ambiguousName) return matchingJobs; + if (matchingJobs.length > 0 && !ambiguousName) { + for (const job of matchingJobs) { + const detail = STATE_PUSH_JOB_IDS.has(jobId) && job.id ? await getStatePushError(job.id) : ""; + failedJobs.push({ name: job.name, html_url: job.html_url, ...(detail ? { error_detail: detail } : {}) }); + } + continue; + } core.warning(`Could not uniquely match failed job ID ${jobId} to its API display name; linking to the workflow run`); - return [{ name: jobId, html_url: process.env.GH_AW_RUN_URL || null }]; - }); + failedJobs.push({ name: jobId, html_url: process.env.GH_AW_RUN_URL || null }); + } + return failedJobs; } /** diff --git a/actions/setup/js/report_failed_jobs.test.cjs b/actions/setup/js/report_failed_jobs.test.cjs index 070b6970afb..c854c1c9e5a 100644 --- a/actions/setup/js/report_failed_jobs.test.cjs +++ b/actions/setup/js/report_failed_jobs.test.cjs @@ -174,6 +174,7 @@ describe("getFailedNonBuiltinJobs", () => { ], }, }); + await main(); expect(global.github.rest.issues.create).toHaveBeenCalledWith( expect.objectContaining({ title: "[aw] Failed jobs: Smoke Gemini", body: expect.stringContaining("- [`Build project`](https://github.com/owner/repo/actions/runs/123/jobs/789)") }) @@ -181,6 +182,49 @@ describe("getFailedNonBuiltinJobs", () => { expect(global.github.rest.issues.create.mock.calls[0][0].body).not.toContain("https://github.com/owner/repo/actions/runs/123/jobs/456"); }); + it.each(["push_repo_memory", "push_ledger_changes", "push_evals_state"])("includes persistent git error text in the %s failure issue", async jobId => { + vi.stubEnv("GH_AW_JOB_RESULTS", JSON.stringify({ [jobId]: { result: "failure" } })); + global.github.rest.actions.listJobsForWorkflowRun.mockResolvedValue({ + data: { jobs: [{ id: 789, name: jobId, conclusion: "failure", html_url: "https://github.com/owner/repo/actions/runs/123/job/789" }] }, + }); + global.github.rest.actions.downloadJobLogsForWorkflowRun = vi.fn().mockResolvedValue({ + data: "2026-10-10T00:00:00Z ordinary log output\n2026-10-10T00:00:01Z remote: error: GH013: Repository rule violations\n2026-10-10T00:00:02Z ##[error]Failed to push changes after 11 attempts: non-fast-forward", + }); + await main(); + const body = global.github.rest.issues.create.mock.calls[0][0].body; + expect(body).toContain("GH013: Repository rule violations"); + expect(body).toContain("non-fast-forward"); + expect(body).not.toContain("ordinary log output"); + expect(global.github.rest.actions.downloadJobLogsForWorkflowRun).toHaveBeenCalledWith({ owner: "owner", repo: "repo", job_id: 789 }); + }); + + it("still reports a state-push failure when its job log is unavailable", async () => { + vi.stubEnv("GH_AW_JOB_RESULTS", JSON.stringify({ push_repo_memory: { result: "failure" } })); + global.github.rest.actions.listJobsForWorkflowRun.mockResolvedValue({ + data: { jobs: [{ id: 789, name: "push_repo_memory", conclusion: "failure", html_url: "https://github.com/owner/repo/actions/runs/123/job/789" }] }, + }); + global.github.rest.actions.downloadJobLogsForWorkflowRun = vi.fn().mockRejectedValue(new Error("403")); + await main(); + expect(global.github.rest.issues.create).toHaveBeenCalled(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not retrieve error details")); + }); + + it("redacts credentials and neutralizes Markdown from binary job logs", async () => { + vi.stubEnv("GH_AW_JOB_RESULTS", JSON.stringify({ push_evals_state: { result: "failure" } })); + global.github.rest.actions.listJobsForWorkflowRun.mockResolvedValue({ + data: { jobs: [{ id: 789, name: "push_evals_state", conclusion: "failure", html_url: null }] }, + }); + const credential = ["user", "password"].join(":"); + global.github.rest.actions.downloadJobLogsForWorkflowRun = vi.fn().mockResolvedValue({ + data: Buffer.from(`fatal: non-fast-forward https://${credential}@example.com/repo Authorization: ${["Basic", "encoded-value"].join(" ")} \`\`\` `), + }); + await main(); + const body = global.github.rest.issues.create.mock.calls[0][0].body; + expect(body).toContain("non-fast-forward"); + expect(body).not.toMatch(/password|encoded-value|/); + expect((body.match(/```/g) || []).length).toBe(2); + }); + it.each(["", "invalid JSON", "null", "[]"])("rejects invalid job result metadata %j rather than falling back to name-based filtering", async metadata => { vi.stubEnv("GH_AW_JOB_RESULTS", metadata); await expect(getFailedNonBuiltinJobs()).rejects.toThrow(); From d704c325813af5ce43ff98bca0ad8a71a1eb22ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:50:20 +0000 Subject: [PATCH 3/5] Fix type-safe parsing of state push error diagnostics Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 2 +- actions/setup/js/report_failed_jobs.cjs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 19051ea1638..01205bf461d 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -279,7 +279,7 @@ async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl }); if (result.exitCode !== 0) { const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim(); - const secrets = [pushToken, process.env.GH_TOKEN, process.env.GITHUB_TOKEN, process.env.GITHUB_APP_TOKEN].filter(Boolean); + const secrets = [pushToken, process.env.GH_TOKEN, process.env.GITHUB_TOKEN, process.env.GITHUB_APP_TOKEN].filter(secret => typeof secret === "string"); const detail = redactBuiltInPatterns(redactSecrets(output, secrets).content) .content.replace(/(https?:\/\/)[^/\s@]+@/gi, "$1[REDACTED]@") .replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, "$1[REDACTED]"); diff --git a/actions/setup/js/report_failed_jobs.cjs b/actions/setup/js/report_failed_jobs.cjs index d806c8b841d..437e05c8fe7 100644 --- a/actions/setup/js/report_failed_jobs.cjs +++ b/actions/setup/js/report_failed_jobs.cjs @@ -23,7 +23,10 @@ const STATE_PUSH_JOB_IDS = new Set(["push_ledger_changes", "push_repo_memory", " async function getStatePushError(jobId) { try { const response = await github.rest.actions.downloadJobLogsForWorkflowRun({ ...context.repo, job_id: jobId }); - const log = typeof response.data === "string" ? response.data : Buffer.from(response.data).toString("utf8"); + if (typeof response.data !== "string" && !(response.data instanceof ArrayBuffer) && !(response.data instanceof Uint8Array)) { + throw new TypeError(`${SAFE_OUTPUT_E001}: Unsupported state-push job log response`); + } + const log = typeof response.data === "string" ? response.data : Buffer.from(response.data instanceof ArrayBuffer ? new Uint8Array(response.data) : response.data).toString("utf8"); const errors = log.split(/\r?\n/).filter(line => /##\[error\]|fatal:|error:|remote: error:|!\s+\[rejected\]/i.test(line)); const detail = redactBuiltInPatterns(errors.slice(-20).join("\n")) .content.replace(/(https?:\/\/)[^/\s@]+@/gi, "$1[REDACTED]@") From 091a10bc8544e89549f0536d46c22a349afca9ad Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sat, 10 Oct 2026 13:35:30 -0700 Subject: [PATCH 4/5] Repair push rejection fixtures and cover retry diagnostics Update push-handler mocks to return captured git exit codes and stderr, preserving non-fast-forward fallback and deleted-branch assertions. Surface ls-remote retry failures and verify plain-text job logs across supported byte formats. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/push_experiment_state.cjs | 4 +-- .../setup/js/push_experiment_state.test.cjs | 14 +++++++--- .../js/push_to_pull_request_branch.test.cjs | 26 +++++++++++++------ actions/setup/js/report_failed_jobs.cjs | 2 +- actions/setup/js/report_failed_jobs.test.cjs | 20 +++++++++++--- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/actions/setup/js/push_experiment_state.cjs b/actions/setup/js/push_experiment_state.cjs index 9df89741f6b..bb753159c0e 100644 --- a/actions/setup/js/push_experiment_state.cjs +++ b/actions/setup/js/push_experiment_state.cjs @@ -574,8 +574,8 @@ async function main() { core.info(`Fetch of branch "${branchName}" on retry failed (non-fatal): ${getErrorMessage(fetchErr)}`); } } - } catch { - // ls-remote failed — ignored, keep existing baseRef. + } catch (remoteErr) { + core.warning(`Could not refresh baseRef for branch "${branchName}" on retry; keeping existing baseRef: ${getErrorMessage(remoteErr)}`); } } else { core.setFailed(`Failed to push ${stateLabel} after ${MAX_RETRIES + 1} attempts: ${errMsg}`); diff --git a/actions/setup/js/push_experiment_state.test.cjs b/actions/setup/js/push_experiment_state.test.cjs index 37aed21f048..207e2854be9 100644 --- a/actions/setup/js/push_experiment_state.test.cjs +++ b/actions/setup/js/push_experiment_state.test.cjs @@ -130,18 +130,19 @@ describe("push_experiment_state", () => { statSpy.mockRestore(); }); - it.each([false, true])("authenticates retry fetches and advances the base only after a successful fetch (fetch fails: %s)", async fetchFails => { + it.each(["none", "fetch", "ls-remote"])("authenticates retry operations and advances the base only after a successful fetch (failure: %s)", async failure => { const baseRef = "a".repeat(40); const remoteHead = "b".repeat(40); const gitEnv = { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_0: "masked-auth-header" }; const execGitSync = vi.fn(args => { if (args[0] === "status") return "M state.json"; if (args[0] === "rev-parse") return baseRef; - if (fetchFails && args[0] === "fetch" && args[1] === "origin") throw new Error("temporary fetch failure"); + if (failure === "fetch" && args[0] === "fetch" && args[1] === "origin") throw new Error("temporary fetch failure"); return ""; }); const pushSignedCommits = vi.fn().mockRejectedValueOnce(new Error("non-fast-forward")).mockResolvedValue(undefined); const exec = { getExecOutput: vi.fn().mockResolvedValue({ stdout: `${remoteHead}\trefs/heads/evals/test` }) }; + if (failure === "ls-remote") exec.getExecOutput.mockRejectedValue(new Error("temporary ls-remote failure")); const require = createRequire(import.meta.url); const module = { exports: {} }; const workspaceDir = path.join(tmpDir, "workspace"); @@ -164,9 +165,14 @@ describe("push_experiment_state", () => { }); await module.exports.main(); expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "origin", "refs/heads/evals/test"], { cwd: workspaceDir, env: expect.objectContaining(gitEnv) }); - expect(execGitSync).toHaveBeenCalledWith(["fetch", "origin", "refs/heads/evals/test"], expect.objectContaining({ env: expect.objectContaining(gitEnv) })); + if (failure === "ls-remote") { + expect(execGitSync).not.toHaveBeenCalledWith(["fetch", "origin", "refs/heads/evals/test"], expect.anything()); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Could not refresh baseRef for branch "evals/test" on retry; keeping existing baseRef: temporary ls-remote failure')); + } else { + expect(execGitSync).toHaveBeenCalledWith(["fetch", "origin", "refs/heads/evals/test"], expect.objectContaining({ env: expect.objectContaining(gitEnv) })); + } expect(pushSignedCommits).toHaveBeenCalledTimes(2); - expect(pushSignedCommits.mock.calls[1][0].baseRef).toBe(fetchFails ? baseRef : remoteHead); + expect(pushSignedCommits.mock.calls[1][0].baseRef).toBe(failure === "none" ? remoteHead : baseRef); expect(mockCore.setFailed).not.toHaveBeenCalled(); }); 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 9e2e581262b..132405a8586 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -295,11 +295,13 @@ describe("push_to_pull_request_branch.cjs", () => { if (argList[0] === "diff-tree") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (argList[0] === "push" && argList[2] === "feature-branch") { + return { exitCode: 1, stdout: "", stderr: "! [rejected] feature-branch -> feature-branch (non-fast-forward)" }; + } return originalGetExecOutput(cmd, args); }); mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); - mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)")); const module = await loadModule(); const handler = await module.main(config); @@ -1426,11 +1428,13 @@ index 0000000..abc1234 if (argList[0] === "diff-tree") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (argList[0] === "push" && argList[2] === "feature-branch") { + return { exitCode: 1, stdout: "", stderr: "! [rejected] feature-branch -> feature-branch (non-fast-forward)" }; + } return originalGetExecOutput(cmd, args); }); mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); - mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)")); const module = await loadModule(); const handler = await module.main({ fallback_as_pull_request: false }); @@ -1487,6 +1491,9 @@ index 0000000..abc1234 if (argList[0] === "diff-tree") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (argList[0] === "push" && argList[2] === "feature-branch") { + return { exitCode: 1, stdout: "", stderr: "! [rejected] feature-branch -> feature-branch (non-fast-forward)" }; + } if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") { return { exitCode: 1, @@ -1499,8 +1506,6 @@ index 0000000..abc1234 // GraphQL call fails, triggering fallback to git push mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); - // Git push fails with non-fast-forward, triggering fallback branch creation - mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)")); const module = await loadModule(); const handler = await module.main({}); @@ -1556,13 +1561,14 @@ index 0000000..abc1234 if (argList[0] === "diff" && argList[1] === "--name-status") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (argList[0] === "push") { + return { exitCode: 1, stdout: "", stderr: "remote: Internal Server Error" }; + } return originalGetExecOutput(cmd, args); }); // GraphQL call fails, triggering fallback to git push mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); - // Fallback git push fails - mockExec.exec.mockRejectedValueOnce(new Error("remote: Internal Server Error")); const module = await loadModule(); const handler = await module.main({}); @@ -1985,6 +1991,9 @@ index 0000000..abc1234 if (argList[0] === "diff-tree") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (argList[0] === "push" && argList[2] === "feature-branch") { + return { exitCode: 1, stdout: "", stderr: "! [rejected] feature-branch -> feature-branch (non-fast-forward)" }; + } // Fallback branch push rejected for workflows scope if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") { return { @@ -1997,7 +2006,6 @@ index 0000000..abc1234 }); mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); - mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)")); const module = await loadModule(); const handler = await module.main({}); @@ -2125,6 +2133,9 @@ index 0000000..abc1234 if (argList[0] === "diff-tree") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (argList[0] === "push" && argList[2] === "feature-branch") { + return { exitCode: 1, stdout: "", stderr: "! [rejected] feature-branch -> feature-branch (non-fast-forward)" }; + } // Fallback branch push rejected for workflows scope if (cmd === "git" && argList[0] === "push" && argList[1] === "origin") { return { @@ -2137,7 +2148,6 @@ index 0000000..abc1234 }); mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection")); - mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)")); const module = await loadModule(); const handler = await module.main({}); diff --git a/actions/setup/js/report_failed_jobs.cjs b/actions/setup/js/report_failed_jobs.cjs index 437e05c8fe7..63db864fae8 100644 --- a/actions/setup/js/report_failed_jobs.cjs +++ b/actions/setup/js/report_failed_jobs.cjs @@ -16,7 +16,7 @@ const FAILED_JOBS_ISSUE_EXPIRES_HOURS = 24 * 7; // 1 week const STATE_PUSH_JOB_IDS = new Set(["push_ledger_changes", "push_repo_memory", "push_evals_state", "push_experiment_state"]); /** - * Read only error lines from an already-masked state-push job log. + * Read error lines from an already-masked plain-text job log, not a workflow-log ZIP archive. * @param {number} jobId * @returns {Promise} */ diff --git a/actions/setup/js/report_failed_jobs.test.cjs b/actions/setup/js/report_failed_jobs.test.cjs index c854c1c9e5a..0121fd8839c 100644 --- a/actions/setup/js/report_failed_jobs.test.cjs +++ b/actions/setup/js/report_failed_jobs.test.cjs @@ -209,15 +209,15 @@ describe("getFailedNonBuiltinJobs", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not retrieve error details")); }); - it("redacts credentials and neutralizes Markdown from binary job logs", async () => { + it.each(["buffer", "uint8array", "arraybuffer"])("redacts credentials and neutralizes Markdown from plain-text job logs returned as %s", async format => { vi.stubEnv("GH_AW_JOB_RESULTS", JSON.stringify({ push_evals_state: { result: "failure" } })); global.github.rest.actions.listJobsForWorkflowRun.mockResolvedValue({ data: { jobs: [{ id: 789, name: "push_evals_state", conclusion: "failure", html_url: null }] }, }); const credential = ["user", "password"].join(":"); - global.github.rest.actions.downloadJobLogsForWorkflowRun = vi.fn().mockResolvedValue({ - data: Buffer.from(`fatal: non-fast-forward https://${credential}@example.com/repo Authorization: ${["Basic", "encoded-value"].join(" ")} \`\`\` `), - }); + const bytes = Buffer.from(`fatal: non-fast-forward https://${credential}@example.com/repo Authorization: ${["Basic", "encoded-value"].join(" ")} \`\`\` `); + const data = format === "buffer" ? bytes : format === "uint8array" ? new Uint8Array(bytes) : new Uint8Array(bytes).buffer; + global.github.rest.actions.downloadJobLogsForWorkflowRun = vi.fn().mockResolvedValue({ data }); await main(); const body = global.github.rest.issues.create.mock.calls[0][0].body; expect(body).toContain("non-fast-forward"); @@ -225,6 +225,18 @@ describe("getFailedNonBuiltinJobs", () => { expect((body.match(/```/g) || []).length).toBe(2); }); + it("warns and preserves the failure issue when the log response is not text or bytes", async () => { + vi.stubEnv("GH_AW_JOB_RESULTS", JSON.stringify({ push_evals_state: { result: "failure" } })); + global.github.rest.actions.listJobsForWorkflowRun.mockResolvedValue({ + data: { jobs: [{ id: 789, name: "push_evals_state", conclusion: "failure", html_url: null }] }, + }); + global.github.rest.actions.downloadJobLogsForWorkflowRun = vi.fn().mockResolvedValue({ data: { message: "not a job log" } }); + await main(); + expect(global.github.rest.issues.create).toHaveBeenCalled(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not retrieve error details")); + expect(global.github.rest.issues.create.mock.calls[0][0].body).not.toContain("not a job log"); + }); + it.each(["", "invalid JSON", "null", "[]"])("rejects invalid job result metadata %j rather than falling back to name-based filtering", async metadata => { vi.stubEnv("GH_AW_JOB_RESULTS", metadata); await expect(getFailedNonBuiltinJobs()).rejects.toThrow(); From 1b76cde43025fed4cb951f21d5f411a657364d06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:07:40 +0000 Subject: [PATCH 5/5] Prevent git diagnostics from entering files Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/git_helpers.cjs | 1 + actions/setup/js/git_helpers.test.cjs | 6 ++ actions/setup/js/push_experiment_state.cjs | 2 +- .../setup/js/push_experiment_state.test.cjs | 2 +- actions/setup/js/push_repo_memory.cjs | 11 ---- actions/setup/js/push_repo_memory.test.cjs | 9 +-- actions/setup/js/push_signed_commits.cjs | 55 +++++++++++++------ actions/setup/js/push_signed_commits.test.cjs | 14 ++++- 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index ddf5a831b23..a0c8c2ac399 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -134,6 +134,7 @@ function execGitSync(args, options = {}) { timeout: defaultTimeoutMs, killSignal: "SIGKILL", ...spawnOptions, + stdio: spawnOptions.stdio === "inherit" ? "pipe" : spawnOptions.stdio, env: safeEnv, }); diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index f8e7e5bc601..b9fc2047084 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -118,6 +118,12 @@ describe("git_helpers.cjs", () => { expect(result).toContain("git version"); }); + it("captures inherited git output for Actions logging", async () => { + const { execGitSync } = await import("./git_helpers.cjs"); + + expect(execGitSync(["--version"], { stdio: "inherit" })).toContain("git version"); + }); + it("should not call core.error when suppressLogs is true", async () => { const { execGitSync } = await import("./git_helpers.cjs"); diff --git a/actions/setup/js/push_experiment_state.cjs b/actions/setup/js/push_experiment_state.cjs index bb753159c0e..f9c67adeed3 100644 --- a/actions/setup/js/push_experiment_state.cjs +++ b/actions/setup/js/push_experiment_state.cjs @@ -561,7 +561,7 @@ async function main() { // pushSignedCommits can resolve the new baseRef in git rev-list. try { const gitEnv = { ...process.env, ...getGitAuthEnv(ghToken) }; - const { stdout: lsOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { cwd: workspaceDir, env: gitEnv }); + const { stdout: lsOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { cwd: workspaceDir, env: gitEnv, silent: true }); const remoteHead = lsOut.trim().split(/\s+/)[0] || ""; if (remoteHead && remoteHead !== currentBaseRef) { // Fetch the updated branch history into the local repo so pushSignedCommits diff --git a/actions/setup/js/push_experiment_state.test.cjs b/actions/setup/js/push_experiment_state.test.cjs index 207e2854be9..f7751c6b60c 100644 --- a/actions/setup/js/push_experiment_state.test.cjs +++ b/actions/setup/js/push_experiment_state.test.cjs @@ -164,7 +164,7 @@ describe("push_experiment_state", () => { process: { env: { GH_AW_STATE_BRANCH: "evals/test", GH_AW_STATE_DIR: tmpDir, GITHUB_WORKSPACE: workspaceDir, GH_TOKEN: "test-token" } }, }); await module.exports.main(); - expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "origin", "refs/heads/evals/test"], { cwd: workspaceDir, env: expect.objectContaining(gitEnv) }); + expect(exec.getExecOutput).toHaveBeenCalledWith("git", ["ls-remote", "origin", "refs/heads/evals/test"], { cwd: workspaceDir, env: expect.objectContaining(gitEnv), silent: true }); if (failure === "ls-remote") { expect(execGitSync).not.toHaveBeenCalledWith(["fetch", "origin", "refs/heads/evals/test"], expect.anything()); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Could not refresh baseRef for branch "evals/test" on retry; keeping existing baseRef: temporary ls-remote failure')); diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index ab5e324b0ab..4067760cc68 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -34,17 +34,6 @@ function getCustomValidationFailureDetail({ stdout, stderr }) { async function setPushRepoMemoryFailure(message) { const safeMessage = redactFailureSummary(message); core.info(`Repo-memory push failed: ${safeMessage}`); - const summary = core.summary; - if (summary && typeof summary.addRaw === "function" && typeof summary.write === "function") { - const safeSummaryMessage = safeMessage.replace(/```/g, "``\u200b`"); - try { - await summary.addRaw(`### Repo-memory push failed\n\n\`\`\`text\n${safeSummaryMessage}\n\`\`\`\n`).write(); - } catch (error) { - if (typeof core.warning === "function") { - core.warning(`Failed to write repo-memory failure summary: ${redactFailureSummary(getErrorMessage(error))}`); - } - } - } return core.setFailed(safeMessage); } diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index 6e9e2b62ac0..66c59c58930 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -2205,12 +2205,8 @@ describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegatio it("should fail deterministic validation errors without retrying", async () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "repo-memory-validation-")); const setFailed = vi.fn(); - const summaryContents = []; const summary = { - addRaw: vi.fn(content => { - summaryContents.push(content); - return { write: vi.fn().mockResolvedValue(undefined) }; - }), + addRaw: vi.fn(), write: vi.fn(), }; const previousGitHubToken = process.env.GH_TOKEN; @@ -2238,8 +2234,7 @@ describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegatio expect(pushed).toBe(false); expect(setFailed).toHaveBeenCalledWith("Failed to push changes: ERR_VALIDATION: merge commit detected with [REDACTED]"); expect(global.core.info).toHaveBeenCalledWith("Repo-memory push failed: Failed to push changes: ERR_VALIDATION: merge commit detected with [REDACTED]"); - expect(summaryContents.join("\n")).toContain("ERR_VALIDATION: merge commit detected with [REDACTED]"); - expect(summaryContents.join("\n")).not.toContain("not-a-real-token"); + expect(summary.addRaw).not.toHaveBeenCalled(); expect(isDeterministicPushValidationError("ERR_VALIDATION: policy violation")).toBe(true); } finally { if (previousGitHubToken === undefined) { diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 01205bf461d..5bbf7b609fd 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -15,6 +15,25 @@ const { overridePersistedExtraheader, restorePersistedExtraheader } = require(". const { getErrorMessage } = require("./error_helpers.cjs"); const { redactSecrets, redactBuiltInPatterns } = require("./redact_secrets.cjs"); const ERROR_CODE_PREFIX_RE = /^(?:ERR_[A-Z_]+|E\d{3}):\s*/; + +/** + * Capture Git output instead of writing it directly to process streams, where + * Actions secret masking is bypassed. + * @param {string[]} args + * @param {Record} [options] + */ +function getGitExecOutput(args, options = {}) { + return exec.getExecOutput("git", args, { ...options, silent: true }); +} + +/** + * @param {string[]} args + * @param {Record} [options] + */ +function execGitCommand(args, options = {}) { + return exec.exec("git", args, { ...options, silent: true }); +} + /** * Strip the single leading `ERR_*: ` or `E001: ` sentinel from a message. * Each sentinel error in this file carries exactly one such prefix, so one @@ -170,7 +189,7 @@ function unquoteCPath(s) { async function readBlobAsBase64(blobHash, cwd) { /** @type {Buffer[]} */ const chunks = []; - await exec.exec("git", ["cat-file", "blob", blobHash], { + await execGitCommand(["cat-file", "blob", blobHash], { cwd, silent: true, listeners: { @@ -234,7 +253,7 @@ function maybeReplaceTemporaryIdsInBase64Content(base64Content, temporaryIdMap, */ async function lsRemoteHeadOid(branch, cwd, gitAuthEnv, pushRemoteUrl, pushToken) { const remote = pushRemoteUrl || "origin"; - const doLsRemote = () => exec.getExecOutput("git", ["ls-remote", remote, `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } }); + const doLsRemote = () => getGitExecOutput(["ls-remote", remote, `refs/heads/${branch}`], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } }); let result; if (pushRemoteUrl && pushToken) { const githubServerUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); @@ -272,7 +291,7 @@ async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv, pushRemoteUrl const pushArgs = pushRemoteUrl ? ["push", pushRemoteUrl, branch] : ["push", "origin", branch]; const pushOnce = async () => { await require("./work_queue_git_effects.cjs").assertGitPushAuthorized({ remote: pushRemoteUrl || "origin", branch, cwd, gitAuthEnv }); - const result = await exec.getExecOutput("git", pushArgs, { + const result = await getGitExecOutput(pushArgs, { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, ignoreReturnCode: true, @@ -352,7 +371,7 @@ function validateSynthesizedFileChanges(additions, deletions, validationConfig) * @returns {Promise} */ async function resolveLocalHeadSha(cwd) { - const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], { cwd }); + const { stdout } = await getGitExecOutput(["rev-parse", "HEAD"], { cwd }); return stdout.trim(); } @@ -447,7 +466,7 @@ async function pushSignedCommits({ /** @type {string | undefined} */ let baseRefOid; try { - const { stdout: baseRefOut } = await exec.getExecOutput("git", ["rev-parse", `${baseRef}^{commit}`], { cwd }); + const { stdout: baseRefOut } = await getGitExecOutput(["rev-parse", `${baseRef}^{commit}`], { cwd }); const trimmedBaseRefOid = baseRefOut.trim(); if (OID_PATTERN.test(trimmedBaseRefOid)) { baseRefOid = trimmedBaseRefOid; @@ -466,7 +485,7 @@ async function pushSignedCommits({ // Using --parents emits each line as " [ ...]", which lets us detect merge commits // (more than one parent) in a single subprocess call without iterating each SHA individually. const revListBase = baseRefOid ?? baseRef; - const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${revListBase}..HEAD`], { cwd }); + const { stdout: revListOut } = await getGitExecOutput(["rev-list", "--parents", "--topo-order", "--reverse", `${revListBase}..HEAD`], { cwd }); const revListEntriesRaw = revListOut .trim() .split("\n") @@ -499,7 +518,7 @@ async function pushSignedCommits({ if (firstGraphqlParentOid) { try { // Partial clones may fetch a missing remote head during this probe. - const ancestryCheck = await exec.getExecOutput("git", ["merge-base", "--is-ancestor", firstGraphqlParentOid, "HEAD"], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, ignoreReturnCode: true }); + const ancestryCheck = await getGitExecOutput(["merge-base", "--is-ancestor", firstGraphqlParentOid, "HEAD"], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, ignoreReturnCode: true }); graphqlParentIsAncestorOfHead = ancestryCheck.exitCode === 0; } catch { // Ancestry probe failed — ignored, keep the default (true) and avoid rewrite. @@ -515,7 +534,7 @@ async function pushSignedCommits({ // those on-demand promisor fetches can authenticate in private repos even // after checkout credentials have been scrubbed from .git/config. const runRebase = async () => { - const result = await exec.getExecOutput("git", ["rebase", "--onto", firstGraphqlParentOid, firstReplayParentOid, "HEAD"], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, ignoreReturnCode: true }); + const result = await getGitExecOutput(["rebase", "--onto", firstGraphqlParentOid, firstReplayParentOid, "HEAD"], { cwd, env: { ...process.env, ...(gitAuthEnv || {}) }, ignoreReturnCode: true }); return result; }; let rebaseResult = await runRebase(); @@ -531,14 +550,14 @@ async function pushSignedCommits({ } if (rebaseResolved) { - rebaseResult = await exec.getExecOutput("git", ["rebase", "--continue"], { + rebaseResult = await getGitExecOutput(["rebase", "--continue"], { cwd, env: { ...process.env, ...(gitAuthEnv || {}), GIT_EDITOR: "true" }, ignoreReturnCode: true, }); if (rebaseResult.exitCode !== 0) { try { - await exec.exec("git", ["rebase", "--abort"], { cwd }); + await execGitCommand(["rebase", "--abort"], { cwd }); } catch { // Ignore cleanup failures. } @@ -548,7 +567,7 @@ async function pushSignedCommits({ } else if (isPartialCloneObjectFailure(combinedOutput)) { // Always abort the in-progress rebase before attempting recovery. try { - await exec.exec("git", ["rebase", "--abort"], { cwd }); + await execGitCommand(["rebase", "--abort"], { cwd }); } catch { // Ignore cleanup failures. } @@ -563,7 +582,7 @@ async function pushSignedCommits({ // which would download a large monorepo's entire history. let headOid; try { - const { stdout: headOut } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], { cwd }); + const { stdout: headOut } = await getGitExecOutput(["rev-parse", "HEAD"], { cwd }); headOid = headOut.trim(); } catch { // HEAD cannot be resolved — ignored, fall back to the known range anchors. @@ -581,7 +600,7 @@ async function pushSignedCommits({ rebaseResult = await runRebase(); if (rebaseResult.exitCode !== 0) { try { - await exec.exec("git", ["rebase", "--abort"], { cwd }); + await execGitCommand(["rebase", "--abort"], { cwd }); } catch { // Ignore cleanup failures. } @@ -600,7 +619,7 @@ async function pushSignedCommits({ } } else { try { - await exec.exec("git", ["rebase", "--abort"], { cwd }); + await execGitCommand(["rebase", "--abort"], { cwd }); } catch { // Ignore cleanup failures. } @@ -609,7 +628,7 @@ async function pushSignedCommits({ ); } } - const { stdout: rebasedRevListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); + const { stdout: rebasedRevListOut } = await getGitExecOutput(["rev-list", "--parents", "--topo-order", "--reverse", `${firstGraphqlParentOid}..HEAD`], { cwd }); revListEntries = rebasedRevListOut .trim() .split("\n") @@ -663,7 +682,7 @@ async function pushSignedCommits({ // Use git diff-tree --raw to obtain file mode information per changed file. // Format: : [score]\t[<\t>] // Fields: [0]=srcMode, [1]=dstMode, [2]=srcHash, [3]=dstHash, [4]=status - const { stdout: rawDiffOut } = await exec.getExecOutput("git", ["diff-tree", "-r", "--raw", sha], { cwd }); + const { stdout: rawDiffOut } = await getGitExecOutput(["diff-tree", "-r", "--raw", sha], { cwd }); for (const line of rawDiffOut.trim().split("\n").filter(Boolean)) { // Raw format lines start with ':'; skip the commit SHA header line and any other non-raw lines @@ -783,7 +802,7 @@ async function pushSignedCommits({ expectedHeadOid = baseRefOid; core.info(`pushSignedCommits: using baseRef OID for initial branch creation: ${expectedHeadOid}`); } else { - const { stdout: parentOut } = await exec.getExecOutput("git", ["rev-parse", `${sha}^`], { cwd }); + const { stdout: parentOut } = await getGitExecOutput(["rev-parse", `${sha}^`], { cwd }); expectedHeadOid = parentOut.trim(); } if (!expectedHeadOid) { @@ -825,7 +844,7 @@ async function pushSignedCommits({ } // Full commit message (subject + body) - const { stdout: msgOut } = await exec.getExecOutput("git", ["log", "-1", "--format=%B", sha], { cwd }); + const { stdout: msgOut } = await getGitExecOutput(["log", "-1", "--format=%B", sha], { cwd }); const message = msgOut.trim(); const headline = message.split("\n")[0]; const body = message.split("\n").slice(1).join("\n").trim(); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 1c07cb36c31..6b4a690ccec 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -305,7 +305,18 @@ describe("push_signed_commits integration tests", () => { // Push the branch so ls-remote can resolve its OID execGit(["push", "-u", "origin", "feature-branch"], { cwd: workDir }); - global.exec = makeRealExec(workDir); + const realExec = makeRealExec(workDir); + const execOptions = []; + global.exec = { + getExecOutput: async (...args) => { + execOptions.push(args[2]); + return realExec.getExecOutput(...args); + }, + exec: async (...args) => { + execOptions.push(args[2]); + return realExec.exec(...args); + }, + }; const githubClient = makeMockGithubClient(); await pushSignedCommits({ @@ -318,6 +329,7 @@ describe("push_signed_commits integration tests", () => { }); expect(githubClient.graphql).toHaveBeenCalledTimes(1); + expect(execOptions.every(options => options.silent)).toBe(true); // Verify the mutation query targets createCommitOnBranch const [query, variables] = githubClient.graphql.mock.calls[0]; expect(query).toContain("createCommitOnBranch");