From 02018314e3c93c1339ba177d16d9838c8376aa6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:46:26 +0000 Subject: [PATCH 1/4] Initial plan From ac16dbfb86fecc9323eb84d571625ac004286678 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:03:47 +0000 Subject: [PATCH 2/4] Warn and handle non-100644 file modes (symlinks, executables) in push_signed_commits.cjs Agent-Logs-Url: https://github.com/github/gh-aw/sessions/727c7685-88df-4f8b-8a4e-f8f835e30ceb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.test.cjs | 9 +- actions/setup/js/push_signed_commits.cjs | 63 +++++++++-- actions/setup/js/push_signed_commits.test.cjs | 104 ++++++++++++++++++ 3 files changed, 163 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index b8aa415ed26..e6840627545 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -1491,7 +1491,14 @@ describe("create_pull_request - copilot assignee on fallback issues", () => { global.exec = { exec: vi.fn().mockResolvedValue(0), - getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "main", stderr: "" }), + getExecOutput: vi.fn().mockImplementation(async (program, args) => { + // Return empty for rev-list so pushSignedCommits exits early (no commits to replay). + // These tests focus on copilot assignment, not the signed-commit push path. + if (program === "git" && args[0] === "rev-list") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "main", stderr: "" }; + }), }; delete require.cache[require.resolve("./create_pull_request.cjs")]; diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index e82816d696c..19dc04b2b45 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -111,26 +111,65 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c core.info(`pushSignedCommits: commit message headline: "${headline}"`); // File changes for this commit (supports Add/Modify/Delete/Rename/Copy) - const { stdout: nameStatusOut } = await exec.getExecOutput("git", ["diff", "--name-status", `${sha}^`, sha], { cwd }); + // Use git diff-tree --raw to obtain file mode information for detecting symlinks and executables. + // The GitHub GraphQL createCommitOnBranch mutation only supports regular file mode 100644: + // - Symlinks (120000) would be silently converted to regular files containing the link target path + // - Executable bits (100755) are silently dropped + const { stdout: rawDiffOut } = await exec.getExecOutput("git", ["diff-tree", "-r", "--raw", sha], { cwd }); /** @type {Array<{path: string, contents: string}>} */ const additions = []; /** @type {Array<{path: string}>} */ const deletions = []; - for (const line of nameStatusOut.trim().split("\n").filter(Boolean)) { - const parts = line.split("\t"); - const status = parts[0]; + 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 + if (!line.startsWith(":")) continue; + + // Format: : [score]\t[<\t>] + // Fields: [0]=srcMode, [1]=dstMode, [2]=srcHash, [3]=dstHash, [4]=status + const tabIdx = line.indexOf("\t"); + if (tabIdx === -1) continue; + + const modeInfo = line.slice(1, tabIdx); // strip leading ':' + const pathPart = line.slice(tabIdx + 1); + + const modeFields = modeInfo.split(" "); + if (modeFields.length < 5) { + core.warning(`pushSignedCommits: unexpected diff-tree output format, skipping line: ${line}`); + continue; + } + const dstMode = modeFields[1]; // destination file mode (e.g. 100644, 100755, 120000) + const status = modeFields[4]; // A=Added, M=Modified, D=Deleted, R=Renamed, C=Copied + + const paths = pathPart.split("\t"); + const filePath = paths[0]; + const renamedPath = paths[1]; // only for renames/copies + if (status === "D") { - deletions.push({ path: parts[1] }); - } else if (status.startsWith("R") || status.startsWith("C")) { - // Rename or Copy: parts[1] = old path, parts[2] = new path - deletions.push({ path: parts[1] }); - const content = fs.readFileSync(path.join(cwd, parts[2])); - additions.push({ path: parts[2], contents: content.toString("base64") }); + deletions.push({ path: filePath }); + } else if (status && (status.startsWith("R") || status.startsWith("C"))) { + // Rename or Copy: filePath = old path, renamedPath = new path + deletions.push({ path: filePath }); + if (dstMode === "120000") { + core.warning(`pushSignedCommits: symlink ${renamedPath} cannot be pushed as a signed commit, falling back to git push`); + throw new Error("symlink file mode requires git push fallback"); + } + 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") }); } else { // Added or Modified - const content = fs.readFileSync(path.join(cwd, parts[1])); - additions.push({ path: parts[1], contents: content.toString("base64") }); + if (dstMode === "120000") { + core.warning(`pushSignedCommits: symlink ${filePath} cannot be pushed as a signed commit, falling back to git push`); + throw new Error("symlink file mode requires git push fallback"); + } + 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") }); } } diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 48469b1d0a0..1ca5f3cddbf 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -574,4 +574,108 @@ describe("push_signed_commits integration tests", () => { expect(callArg.message.body).toBeUndefined(); }); }); + + // ────────────────────────────────────────────────────── + // File mode handling – symlinks and executables + // ────────────────────────────────────────────────────── + + describe("file mode handling", () => { + it("should fall back to git push and warn when commit contains a symlink", async () => { + execGit(["checkout", "-b", "symlink-branch"], { cwd: workDir }); + + // Create a regular file to serve as symlink target + fs.writeFileSync(path.join(workDir, "target.txt"), "Symlink target\n"); + execGit(["add", "target.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add target file"], { cwd: workDir }); + execGit(["push", "-u", "origin", "symlink-branch"], { cwd: workDir }); + + // Add a symlink in a new commit + fs.symlinkSync("target.txt", path.join(workDir, "link.txt")); + execGit(["add", "link.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add symlink"], { cwd: workDir }); + execGit(["push", "origin", "symlink-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "symlink-branch", + // Only replay the symlink commit + baseRef: "symlink-branch^", + cwd: workDir, + }); + + // GraphQL should NOT have been called – symlink triggers fallback before mutation + expect(githubClient.graphql).not.toHaveBeenCalled(); + // Warning about symlink must be emitted + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("symlink link.txt cannot be pushed as a signed commit")); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("falling back to git push")); + + // The commit should be present on the remote via git push fallback + const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/symlink-branch"], { cwd: workDir }); + const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0]; + const localOid = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + expect(remoteOid).toBe(localOid); + }); + + it("should warn about executable bit loss but continue with GraphQL signed commit", async () => { + execGit(["checkout", "-b", "executable-branch"], { cwd: workDir }); + + // Create an executable file + fs.writeFileSync(path.join(workDir, "script.sh"), "#!/bin/bash\necho hello\n"); + fs.chmodSync(path.join(workDir, "script.sh"), 0o755); + execGit(["add", "script.sh"], { cwd: workDir }); + execGit(["commit", "-m", "Add executable script"], { cwd: workDir }); + execGit(["push", "-u", "origin", "executable-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "executable-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + // GraphQL SHOULD still be called – executable bit triggers a warning but not a fallback + expect(githubClient.graphql).toHaveBeenCalledTimes(1); + // Warning about executable bit must be emitted + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("executable bit on script.sh will be lost in signed commit")); + // The file content should be in the additions payload + const callArg = githubClient.graphql.mock.calls[0][1].input; + expect(callArg.fileChanges.additions).toHaveLength(1); + expect(callArg.fileChanges.additions[0].path).toBe("script.sh"); + expect(Buffer.from(callArg.fileChanges.additions[0].contents, "base64").toString()).toContain("echo hello"); + }); + + it("should not warn for regular files (mode 100644)", async () => { + execGit(["checkout", "-b", "regular-file-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "regular.txt"), "Regular file content\n"); + execGit(["add", "regular.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Add regular file"], { cwd: workDir }); + execGit(["push", "-u", "origin", "regular-file-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "regular-file-branch", + baseRef: "origin/main", + cwd: workDir, + }); + + expect(githubClient.graphql).toHaveBeenCalledTimes(1); + // No warnings should be emitted for regular files + expect(mockCore.warning).not.toHaveBeenCalled(); + }); + }); }); From bb1469fea785430e5eb33a25cd17d2456243d28b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:20:31 +0000 Subject: [PATCH 3/4] Address review comments: pre-scan modes before GraphQL, fix copy vs rename, add null checks Agent-Logs-Url: https://github.com/github/gh-aw/sessions/896611d9-098f-40f3-8f4f-f695e6c2e04a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/push_signed_commits.cjs | 161 ++++++++++++++--------- 1 file changed, 98 insertions(+), 63 deletions(-) diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 19dc04b2b45..331f7bcd070 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -48,6 +48,102 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch (branch: ${branch}, repo: ${owner}/${repo})`); try { + // Pre-scan ALL commits: collect file changes and check for unsupported file modes + // BEFORE starting any GraphQL mutations. If a symlink is found mid-loop after some + // commits have already been signed, the remote branch diverges and the git push + // fallback would be rejected as non-fast-forward. + // + // The GitHub GraphQL createCommitOnBranch mutation only supports regular file mode 100644: + // - Symlinks (120000) would be silently converted to regular files containing the link target path + // - Executable bits (100755) are silently dropped + /** @type {Map>} */ + const additionsMap = new Map(); + /** @type {Map>} */ + const deletionsMap = new Map(); + + for (const sha of shas) { + /** @type {Array<{path: string, contents: string}>} */ + const additions = []; + /** @type {Array<{path: string}>} */ + const deletions = []; + + // 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 }); + + 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 + if (!line.startsWith(":")) continue; + + const tabIdx = line.indexOf("\t"); + if (tabIdx === -1) continue; + + const modeFields = line.slice(1, tabIdx).split(" "); // strip leading ':' + if (modeFields.length < 5) { + core.warning(`pushSignedCommits: unexpected diff-tree output format, skipping line: ${line}`); + continue; + } + const dstMode = modeFields[1]; // destination file mode (e.g. 100644, 100755, 120000) + const status = modeFields[4]; // A=Added, M=Modified, D=Deleted, R=Renamed, C=Copied + + const paths = line.slice(tabIdx + 1).split("\t"); + const filePath = paths[0]; + + if (status === "D") { + deletions.push({ path: filePath }); + } else if (status && status.startsWith("R")) { + // Rename: source path is deleted, destination path is added + const renamedPath = paths[1]; + if (!renamedPath) { + core.warning(`pushSignedCommits: rename entry missing destination path, skipping: ${line}`); + continue; + } + deletions.push({ path: filePath }); + if (dstMode === "120000") { + core.warning(`pushSignedCommits: symlink ${renamedPath} cannot be pushed as a signed commit, falling back to git push`); + throw new Error("symlink file mode requires git push fallback"); + } + 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") }); + } else if (status && status.startsWith("C")) { + // Copy: source path is kept (no deletion), only the destination path is added + const copiedPath = paths[1]; + if (!copiedPath) { + core.warning(`pushSignedCommits: copy entry missing destination path, skipping: ${line}`); + continue; + } + if (dstMode === "120000") { + core.warning(`pushSignedCommits: symlink ${copiedPath} cannot be pushed as a signed commit, falling back to git push`); + throw new Error("symlink file mode requires git push fallback"); + } + 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") }); + } else { + // Added or Modified + if (dstMode === "120000") { + core.warning(`pushSignedCommits: symlink ${filePath} cannot be pushed as a signed commit, falling back to git push`); + throw new Error("symlink file mode requires git push fallback"); + } + 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") }); + } + } + + additionsMap.set(sha, additions); + deletionsMap.set(sha, deletions); + } + + // All commits passed the mode checks. Replay via GraphQL. /** @type {string | undefined} */ let lastOid; for (let i = 0; i < shas.length; i++) { @@ -110,69 +206,8 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c const body = message.split("\n").slice(1).join("\n").trim(); core.info(`pushSignedCommits: commit message headline: "${headline}"`); - // File changes for this commit (supports Add/Modify/Delete/Rename/Copy) - // Use git diff-tree --raw to obtain file mode information for detecting symlinks and executables. - // The GitHub GraphQL createCommitOnBranch mutation only supports regular file mode 100644: - // - Symlinks (120000) would be silently converted to regular files containing the link target path - // - Executable bits (100755) are silently dropped - const { stdout: rawDiffOut } = await exec.getExecOutput("git", ["diff-tree", "-r", "--raw", sha], { cwd }); - /** @type {Array<{path: string, contents: string}>} */ - const additions = []; - /** @type {Array<{path: string}>} */ - const deletions = []; - - 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 - if (!line.startsWith(":")) continue; - - // Format: : [score]\t[<\t>] - // Fields: [0]=srcMode, [1]=dstMode, [2]=srcHash, [3]=dstHash, [4]=status - const tabIdx = line.indexOf("\t"); - if (tabIdx === -1) continue; - - const modeInfo = line.slice(1, tabIdx); // strip leading ':' - const pathPart = line.slice(tabIdx + 1); - - const modeFields = modeInfo.split(" "); - if (modeFields.length < 5) { - core.warning(`pushSignedCommits: unexpected diff-tree output format, skipping line: ${line}`); - continue; - } - const dstMode = modeFields[1]; // destination file mode (e.g. 100644, 100755, 120000) - const status = modeFields[4]; // A=Added, M=Modified, D=Deleted, R=Renamed, C=Copied - - const paths = pathPart.split("\t"); - const filePath = paths[0]; - const renamedPath = paths[1]; // only for renames/copies - - if (status === "D") { - deletions.push({ path: filePath }); - } else if (status && (status.startsWith("R") || status.startsWith("C"))) { - // Rename or Copy: filePath = old path, renamedPath = new path - deletions.push({ path: filePath }); - if (dstMode === "120000") { - core.warning(`pushSignedCommits: symlink ${renamedPath} cannot be pushed as a signed commit, falling back to git push`); - throw new Error("symlink file mode requires git push fallback"); - } - 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") }); - } else { - // Added or Modified - if (dstMode === "120000") { - core.warning(`pushSignedCommits: symlink ${filePath} cannot be pushed as a signed commit, falling back to git push`); - throw new Error("symlink file mode requires git push fallback"); - } - 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 additions = additionsMap.get(sha) || []; + const deletions = deletionsMap.get(sha) || []; core.info(`pushSignedCommits: file changes: ${additions.length} addition(s), ${deletions.length} deletion(s)`); /** @type {any} */ From 488bb9b6b405acb43a2fa1a76fa38d050566311d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:11:42 +0000 Subject: [PATCH 4/4] Add changeset --- .changeset/patch-handle-non-100644-file-modes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-handle-non-100644-file-modes.md diff --git a/.changeset/patch-handle-non-100644-file-modes.md b/.changeset/patch-handle-non-100644-file-modes.md new file mode 100644 index 00000000000..069f9e176d7 --- /dev/null +++ b/.changeset/patch-handle-non-100644-file-modes.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Improved signed commit pushes to detect non-100644 file modes ahead of GraphQL mutations, warn on executable-bit loss, and fall back safely for symlinks to avoid branch divergence.