From 2c10b6ac9bf879728437a68c55ed9710baeb9030 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:07:16 +0000 Subject: [PATCH 1/3] Initial plan From 98b68bc115386bcbd8b45853edd8b59cf012a1fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:17:44 +0000 Subject: [PATCH 2/3] fix: read file content from commit objects in push_signed_commits.cjs Replace fs.readFileSync (working tree) with git show : for all three file addition cases (rename, copy, add/modify). Remove unused fs and path imports. Fixes: multi-commit pushes produced wrong file contents for intermediate commits because working tree always reflected HEAD. Resolves #26156 Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c0841d22-80e2-4bd8-995b-019888de359e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index ce652f233be..2d9abd964cc 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -1,10 +1,6 @@ // @ts-check /// -/** @type {typeof import("fs")} */ -const fs = require("fs"); -/** @type {typeof import("path")} */ -const path = require("path"); const { ERR_API } = require("./error_codes.cjs"); /** @@ -180,8 +176,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c if (dstMode === "100755") { core.warning(`pushSignedCommits: executable bit on ${renamedPath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); } - const content = fs.readFileSync(path.join(cwd, renamedPath)); - additions.push({ path: renamedPath, contents: content.toString("base64") }); + const { stdout: renamedContent } = await exec.getExecOutput("git", ["show", `${sha}:${renamedPath}`], { cwd }); + additions.push({ path: renamedPath, contents: Buffer.from(renamedContent).toString("base64") }); } else if (status && status.startsWith("C")) { // Copy: source path is kept (no deletion), only the destination path is added const copiedPath = unquoteCPath(paths[1]); @@ -196,8 +192,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c if (dstMode === "100755") { core.warning(`pushSignedCommits: executable bit on ${copiedPath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); } - const content = fs.readFileSync(path.join(cwd, copiedPath)); - additions.push({ path: copiedPath, contents: content.toString("base64") }); + const { stdout: copiedContent } = await exec.getExecOutput("git", ["show", `${sha}:${copiedPath}`], { cwd }); + additions.push({ path: copiedPath, contents: Buffer.from(copiedContent).toString("base64") }); } else { // Added or Modified if (dstMode === "120000") { @@ -207,8 +203,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c if (dstMode === "100755") { core.warning(`pushSignedCommits: executable bit on ${filePath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); } - const content = fs.readFileSync(path.join(cwd, filePath)); - additions.push({ path: filePath, contents: content.toString("base64") }); + const { stdout: fileContent } = await exec.getExecOutput("git", ["show", `${sha}:${filePath}`], { cwd }); + additions.push({ path: filePath, contents: Buffer.from(fileContent).toString("base64") }); } } From ff19dce71a31ff6bc5ae822992bc5332970456bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:51:34 +0000 Subject: [PATCH 3/3] fix: use raw Buffer listeners for binary-safe blob reads in push_signed_commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract readBlobAsBase64() helper that uses exec.exec with stdout listeners accumulating raw Buffer chunks instead of getExecOutput (which UTF-8 decodes stdout via StringDecoder and corrupts binary files). Update makeRealExec test mock to support listeners.stdout with encoding:null so binary content is preserved in tests. Add regression test verifying that A→B→C multi-commit push sends each commit's own file content, not the working-tree tip. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/cc9df24f-9c1d-48e3-bff9-22fb9ab97b4c Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 36 ++++++++++--- actions/setup/js/push_signed_commits.test.cjs | 54 +++++++++++++++++-- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 2d9abd964cc..802feddda69 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -76,6 +76,33 @@ function unquoteCPath(s) { return Buffer.from(bytes).toString("utf8"); } +/** + * Read a blob from a specific commit as a base64-encoded string using + * `git show :`. The raw bytes emitted by git are collected via + * the `exec.exec` stdout listener so that binary files are not corrupted by + * any UTF-8 decoding layer (unlike `exec.getExecOutput` which always passes + * stdout through a `StringDecoder('utf8')`). + * + * @param {string} sha - Commit SHA to read the blob from + * @param {string} filePath - Repo-relative path of the file + * @param {string} cwd - Working directory of the local git checkout + * @returns {Promise} Base64-encoded file contents + */ +async function readBlobAsBase64(sha, filePath, cwd) { + /** @type {Buffer[]} */ + const chunks = []; + await exec.exec("git", ["show", `${sha}:${filePath}`], { + cwd, + silent: true, + listeners: { + stdout: (/** @type {Buffer} */ data) => { + chunks.push(data); + }, + }, + }); + return Buffer.concat(chunks).toString("base64"); +} + /** * @fileoverview Signed Commit Push Helper * @@ -176,8 +203,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c if (dstMode === "100755") { core.warning(`pushSignedCommits: executable bit on ${renamedPath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); } - const { stdout: renamedContent } = await exec.getExecOutput("git", ["show", `${sha}:${renamedPath}`], { cwd }); - additions.push({ path: renamedPath, contents: Buffer.from(renamedContent).toString("base64") }); + additions.push({ path: renamedPath, contents: await readBlobAsBase64(sha, renamedPath, cwd) }); } else if (status && status.startsWith("C")) { // Copy: source path is kept (no deletion), only the destination path is added const copiedPath = unquoteCPath(paths[1]); @@ -192,8 +218,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c if (dstMode === "100755") { core.warning(`pushSignedCommits: executable bit on ${copiedPath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); } - const { stdout: copiedContent } = await exec.getExecOutput("git", ["show", `${sha}:${copiedPath}`], { cwd }); - additions.push({ path: copiedPath, contents: Buffer.from(copiedContent).toString("base64") }); + additions.push({ path: copiedPath, contents: await readBlobAsBase64(sha, copiedPath, cwd) }); } else { // Added or Modified if (dstMode === "120000") { @@ -203,8 +228,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c if (dstMode === "100755") { core.warning(`pushSignedCommits: executable bit on ${filePath} will be lost in signed commit (GitHub GraphQL does not support mode 100755)`); } - const { stdout: fileContent } = await exec.getExecOutput("git", ["show", `${sha}:${filePath}`], { cwd }); - additions.push({ path: filePath, contents: Buffer.from(fileContent).toString("base64") }); + additions.push({ path: filePath, contents: await readBlobAsBase64(sha, filePath, cwd) }); } } diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index d3c9e6f5084..bcc3f5caf18 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -186,17 +186,24 @@ function makeRealExec(cwd) { /** * @param {string} program * @param {string[]} args - * @param {{ cwd?: string, env?: NodeJS.ProcessEnv }} [opts] + * @param {{ cwd?: string, env?: NodeJS.ProcessEnv, silent?: boolean, listeners?: { stdout?: (data: Buffer) => void } }} [opts] */ exec: async (program, args, opts = {}) => { + const stdoutListener = opts.listeners?.stdout; const result = spawnSync(program, args, { - encoding: "utf8", + // Use raw Buffer encoding when a stdout listener is provided so binary + // content is not corrupted by UTF-8 decoding. + encoding: stdoutListener ? null : "utf8", cwd: opts.cwd ?? cwd, env: opts.env ?? { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: os.tmpdir() }, }); if (result.error) throw result.error; if (result.status !== 0) { - throw new Error(`${program} ${args.join(" ")} failed:\n${result.stderr}`); + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : (result.stderr ?? ""); + throw new Error(`${program} ${args.join(" ")} failed:\n${stderr}`); + } + if (stdoutListener && result.stdout) { + stdoutListener(Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout)); } return result.status ?? 0; }, @@ -324,6 +331,47 @@ describe("push_signed_commits integration tests", () => { expect(headlines).toEqual(["Add file-a.txt", "Add file-b.txt"]); }); + it("each commit in a series should carry its own file content, not the working-tree tip", async () => { + // Regression test for the bug where fs.readFileSync always read from the + // working tree (HEAD), so intermediate commits A and B would contain the + // content of C when A→B→C were replayed. + execGit(["checkout", "-b", "versioned-branch"], { cwd: workDir }); + + fs.writeFileSync(path.join(workDir, "data.txt"), "version A\n"); + execGit(["add", "data.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Version A"], { cwd: workDir }); + + fs.writeFileSync(path.join(workDir, "data.txt"), "version B\n"); + execGit(["add", "data.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Version B"], { cwd: workDir }); + + fs.writeFileSync(path.join(workDir, "data.txt"), "version C\n"); + execGit(["add", "data.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Version C"], { cwd: workDir }); + + execGit(["push", "-u", "origin", "versioned-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "versioned-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + expect(githubClient.graphql).toHaveBeenCalledTimes(3); + const calls = githubClient.graphql.mock.calls.map(c => c[1].input); + + // Each commit must carry its own version of data.txt, not the working-tree tip (C) + expect(Buffer.from(calls[0].fileChanges.additions[0].contents, "base64").toString()).toBe("version A\n"); + expect(Buffer.from(calls[1].fileChanges.additions[0].contents, "base64").toString()).toBe("version B\n"); + expect(Buffer.from(calls[2].fileChanges.additions[0].contents, "base64").toString()).toBe("version C\n"); + }); + it("should include deletions when files are removed in a commit", async () => { execGit(["checkout", "-b", "delete-branch"], { cwd: workDir });