diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index ce652f233be..67e44842b85 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -129,6 +129,7 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c // 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 + // - Submodules/gitlinks (160000) are not supported; the mutation does not accept commit-object entries /** @type {Map>} */ const additionsMap = new Map(); /** @type {Map>} */ @@ -157,13 +158,19 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c 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 srcMode = modeFields[0]; // source file mode (e.g. 100644, 100755, 120000, 160000) + const dstMode = modeFields[1]; // destination file mode (e.g. 100644, 100755, 120000, 160000) const status = modeFields[4]; // A=Added, M=Modified, D=Deleted, R=Renamed, C=Copied const paths = line.slice(tabIdx + 1).split("\t"); const filePath = unquoteCPath(paths[0]); if (status === "D") { + // mode 160000 = gitlink (submodule); GitHub GraphQL createCommitOnBranch does not support submodules + if (srcMode === "160000") { + core.warning(`pushSignedCommits: submodule change detected in ${filePath}, falling back to git push`); + throw new Error("submodule change detected"); + } deletions.push({ path: filePath }); } else if (status && status.startsWith("R")) { // Rename: source path is deleted, destination path is added @@ -173,6 +180,10 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c continue; } deletions.push({ path: filePath }); + if (srcMode === "160000" || dstMode === "160000") { + core.warning(`pushSignedCommits: submodule change detected in ${filePath} -> ${renamedPath}, falling back to git push`); + throw new Error("submodule change detected"); + } 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"); @@ -189,6 +200,10 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c core.warning(`pushSignedCommits: copy entry missing destination path, skipping: ${line}`); continue; } + if (dstMode === "160000") { + core.warning(`pushSignedCommits: submodule change detected in ${copiedPath}, falling back to git push`); + throw new Error("submodule change detected"); + } 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"); @@ -200,6 +215,10 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c additions.push({ path: copiedPath, contents: content.toString("base64") }); } else { // Added or Modified + if (dstMode === "160000") { + core.warning(`pushSignedCommits: submodule change detected in ${filePath}, falling back to git push`); + throw new Error("submodule change detected"); + } 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"); diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index d3c9e6f5084..5ccebb5dca0 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -718,6 +718,43 @@ describe("push_signed_commits integration tests", () => { expect(Buffer.from(callArg.fileChanges.additions[0].contents, "base64").toString()).toContain("echo hello"); }); + it("should fall back to git push and warn when commit contains a submodule entry", async () => { + execGit(["checkout", "-b", "submodule-branch"], { cwd: workDir }); + + // Create a gitlink (mode 160000) entry directly via update-index so we don't + // need a real submodule URL. git diff-tree --raw will report this as mode 160000. + // The cacheinfo format is: ,, + const headSha = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + execGit(["update-index", "--add", "--cacheinfo", `160000,${headSha},mysubmodule`], { cwd: workDir }); + execGit(["commit", "-m", "Add submodule"], { cwd: workDir }); + execGit(["push", "-u", "origin", "submodule-branch"], { cwd: workDir }); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "submodule-branch", + // Only replay the submodule commit + baseRef: "submodule-branch^", + cwd: workDir, + }); + + // GraphQL should NOT have been called – submodule triggers fallback before mutation + expect(githubClient.graphql).not.toHaveBeenCalled(); + // Warning about submodule must be emitted + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("submodule change detected in mysubmodule")); + 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/submodule-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 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");