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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions actions/setup/js/push_signed_commits.cjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
// @ts-check
/// <reference types="@actions/github-script" />

/** @type {typeof import("fs")} */
const fs = require("fs");
/** @type {typeof import("path")} */
const path = require("path");
const { ERR_API } = require("./error_codes.cjs");

/**
Expand Down Expand Up @@ -80,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 <sha>:<path>`. 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<string>} 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
*
Expand Down Expand Up @@ -180,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 content = fs.readFileSync(path.join(cwd, renamedPath));
additions.push({ path: renamedPath, contents: content.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]);
Expand All @@ -196,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 content = fs.readFileSync(path.join(cwd, copiedPath));
additions.push({ path: copiedPath, contents: content.toString("base64") });
additions.push({ path: copiedPath, contents: await readBlobAsBase64(sha, copiedPath, cwd) });
} else {
// Added or Modified
if (dstMode === "120000") {
Expand All @@ -207,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 content = fs.readFileSync(path.join(cwd, filePath));
additions.push({ path: filePath, contents: content.toString("base64") });
additions.push({ path: filePath, contents: await readBlobAsBase64(sha, filePath, cwd) });
}
}

Expand Down
54 changes: 51 additions & 3 deletions actions/setup/js/push_signed_commits.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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 });

Expand Down
Loading