diff --git a/.changeset/patch-use-signed-commit-pushes.md b/.changeset/patch-use-signed-commit-pushes.md
new file mode 100644
index 00000000000..739a8a7f204
--- /dev/null
+++ b/.changeset/patch-use-signed-commit-pushes.md
@@ -0,0 +1,5 @@
+---
+"gh-aw": patch
+---
+
+Replace direct `git push` with GraphQL commit replay so commits pushed by `push_to_pull_request_branch` and `create_pull_request` are GitHub-signed, with fallback to `git push` when GraphQL commit creation is unavailable.
diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs
index db5696d4538..9f21355ab4c 100644
--- a/actions/setup/js/create_pull_request.cjs
+++ b/actions/setup/js/create_pull_request.cjs
@@ -6,6 +6,7 @@ const fs = require("fs");
/** @type {typeof import("crypto")} */
const crypto = require("crypto");
const { updateActivationComment } = require("./update_activation_comment.cjs");
+const { pushSignedCommits } = require("./push_signed_commits.cjs");
const { getTrackerID } = require("./get_tracker_id.cjs");
const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
@@ -81,6 +82,7 @@ function enforcePullRequestLimits(patchContent) {
throw new Error(`E003: Cannot create pull request with more than ${MAX_FILES} files (received ${fileCount})`);
}
}
+
/**
* Generate a patch preview with max 500 lines and 2000 chars for issue body
* @param {string} patchContent - The full patch content
@@ -739,7 +741,14 @@ async function main(config = {}) {
core.info(`Renamed branch to ${branchName}`);
}
- await exec.exec(`git push origin ${branchName}`);
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: `origin/${baseBranch}`,
+ cwd: process.cwd(),
+ });
core.info("Changes pushed to branch");
// Count new commits on PR branch relative to base, used to restrict
@@ -900,7 +909,14 @@ ${patchPreview}`;
core.info(`Renamed branch to ${branchName}`);
}
- await exec.exec(`git push origin ${branchName}`);
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: `origin/${baseBranch}`,
+ cwd: process.cwd(),
+ });
core.info("Empty branch pushed successfully");
// Count new commits (will be 1 from the Initialize commit)
diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs
new file mode 100644
index 00000000000..481de1bf247
--- /dev/null
+++ b/actions/setup/js/push_signed_commits.cjs
@@ -0,0 +1,115 @@
+// @ts-check
+///
+
+/** @type {typeof import("fs")} */
+const fs = require("fs");
+/** @type {typeof import("path")} */
+const path = require("path");
+
+/**
+ * @fileoverview Signed Commit Push Helper
+ *
+ * Pushes local git commits to a remote branch using the GitHub GraphQL
+ * `createCommitOnBranch` mutation, so commits are cryptographically signed
+ * (verified) by GitHub. Falls back to a plain `git push` when the GraphQL
+ * approach is unavailable (e.g. GitHub Enterprise Server instances that do
+ * not support the mutation, or when branch-protection policies reject it).
+ *
+ * Both `create_pull_request.cjs` and `push_to_pull_request_branch.cjs` use
+ * this helper so the signed-commit logic lives in exactly one place.
+ */
+
+/**
+ * Pushes local commits to a remote branch using the GitHub GraphQL
+ * `createCommitOnBranch` mutation so commits are cryptographically signed.
+ * Falls back to `git push` if the GraphQL approach fails (e.g. on GHES).
+ *
+ * @param {object} opts
+ * @param {any} opts.githubClient - Authenticated Octokit client with .graphql()
+ * @param {string} opts.owner - Repository owner
+ * @param {string} opts.repo - Repository name
+ * @param {string} opts.branch - Target branch name
+ * @param {string} opts.baseRef - Git ref of the remote head before commits were applied (used for rev-list)
+ * @param {string} opts.cwd - Working directory of the local git checkout
+ * @param {object} [opts.gitAuthEnv] - Environment variables for git push fallback auth
+ * @returns {Promise}
+ */
+async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv }) {
+ // Collect the commits introduced (oldest-first)
+ const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--reverse", `${baseRef}..HEAD`], { cwd });
+ const shas = revListOut.trim().split("\n").filter(Boolean);
+
+ if (shas.length === 0) {
+ core.info("pushSignedCommits: no new commits to push via GraphQL");
+ return;
+ }
+
+ core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch`);
+
+ try {
+ for (const sha of shas) {
+ // Get the current remote HEAD OID (updated each iteration)
+ const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd });
+ const expectedHeadOid = oidOut.trim().split(/\s+/)[0];
+ if (!expectedHeadOid) {
+ throw new Error(`Could not resolve remote HEAD OID for branch ${branch}`);
+ }
+
+ // Full commit message (subject + body)
+ const { stdout: msgOut } = await exec.getExecOutput("git", ["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();
+
+ // File changes for this commit (supports Add/Modify/Delete/Rename/Copy)
+ const { stdout: nameStatusOut } = await exec.getExecOutput("git", ["diff", "--name-status", `${sha}^`, 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];
+ 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") });
+ } else {
+ // Added or Modified
+ const content = fs.readFileSync(path.join(cwd, parts[1]));
+ additions.push({ path: parts[1], contents: content.toString("base64") });
+ }
+ }
+
+ /** @type {any} */
+ const input = {
+ branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch },
+ message: { headline, ...(body ? { body } : {}) },
+ fileChanges: { additions, deletions },
+ expectedHeadOid,
+ };
+
+ const result = await githubClient.graphql(
+ `mutation($input: CreateCommitOnBranchInput!) {
+ createCommitOnBranch(input: $input) { commit { oid } }
+ }`,
+ { input }
+ );
+ const oid = result?.createCommitOnBranch?.commit?.oid;
+ core.info(`pushSignedCommits: signed commit created: ${oid}`);
+ }
+ core.info(`pushSignedCommits: all ${shas.length} commit(s) pushed as signed commits`);
+ } catch (graphqlError) {
+ core.warning(`pushSignedCommits: GraphQL signed push failed, falling back to git push: ${graphqlError instanceof Error ? graphqlError.message : String(graphqlError)}`);
+ await exec.exec("git", ["push", "origin", branch], {
+ cwd,
+ env: { ...process.env, ...(gitAuthEnv || {}) },
+ });
+ }
+}
+
+module.exports = { pushSignedCommits };
diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs
new file mode 100644
index 00000000000..67e933976c8
--- /dev/null
+++ b/actions/setup/js/push_signed_commits.test.cjs
@@ -0,0 +1,433 @@
+/**
+ * Integration tests for push_signed_commits.cjs
+ *
+ * These tests run REAL git commands to verify that pushSignedCommits:
+ * 1. Correctly enumerates new commits via `git rev-list`
+ * 2. Reads file contents and builds the GraphQL payload
+ * 3. Calls the GitHub GraphQL `createCommitOnBranch` mutation for each commit
+ * 4. Falls back to `git push` when the GraphQL mutation fails
+ *
+ * A bare git repository is used as the stand-in "remote" so that ls-remote
+ * and push commands work without a real network connection.
+ * The GraphQL layer is always mocked because it requires a real GitHub API.
+ */
+
+// @ts-check
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { createRequire } from "module";
+import fs from "fs";
+import path from "path";
+import { spawnSync } from "child_process";
+import os from "os";
+
+const require = createRequire(import.meta.url);
+
+// Import module once – globals are resolved at call time, not import time.
+const { pushSignedCommits } = require("./push_signed_commits.cjs");
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Git helpers (real subprocess – no mocking)
+// ──────────────────────────────────────────────────────────────────────────────
+
+/**
+ * @param {string[]} args
+ * @param {{ cwd?: string, allowFailure?: boolean }} [options]
+ */
+function execGit(args, options = {}) {
+ const result = spawnSync("git", args, {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: "1",
+ HOME: os.tmpdir(),
+ },
+ ...options,
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0 && !options.allowFailure) {
+ throw new Error(`git ${args.join(" ")} failed (cwd=${options.cwd}):\n${result.stderr}`);
+ }
+ return result;
+}
+
+/**
+ * Create a bare repository that acts as the remote "origin".
+ * @returns {string} Path to the bare repository
+ */
+function createBareRepo() {
+ const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), "push-signed-bare-"));
+ execGit(["init", "--bare"], { cwd: bareDir });
+ // Ensure the bare repo uses "main" as the default branch
+ execGit(["symbolic-ref", "HEAD", "refs/heads/main"], { cwd: bareDir });
+ return bareDir;
+}
+
+/**
+ * Clone the bare repo and set up a working copy with an initial commit on `main`.
+ * @param {string} bareDir
+ * @returns {string} Path to the working copy
+ */
+function createWorkingRepo(bareDir) {
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "push-signed-work-"));
+ execGit(["clone", bareDir, "."], { cwd: workDir });
+ execGit(["config", "user.name", "Test User"], { cwd: workDir });
+ execGit(["config", "user.email", "test@example.com"], { cwd: workDir });
+
+ // Initial commit on main
+ fs.writeFileSync(path.join(workDir, "README.md"), "# Test\n");
+ execGit(["add", "."], { cwd: workDir });
+ execGit(["commit", "-m", "Initial commit"], { cwd: workDir });
+ // Rename to main if git defaulted to master
+ execGit(["branch", "-M", "main"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "main"], { cwd: workDir });
+
+ return workDir;
+}
+
+/** @param {string} dir */
+function cleanupDir(dir) {
+ if (dir && fs.existsSync(dir)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Global stubs required by push_signed_commits.cjs
+// ──────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Build an `exec` global stub that runs real git commands via spawnSync.
+ * @param {string} cwd
+ */
+function makeRealExec(cwd) {
+ return {
+ /**
+ * @param {string} program
+ * @param {string[]} args
+ * @param {{ cwd?: string }} [opts]
+ */
+ getExecOutput: async (program, args, opts = {}) => {
+ const result = spawnSync(program, args, {
+ encoding: "utf8",
+ cwd: opts.cwd ?? cwd,
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: "1",
+ HOME: os.tmpdir(),
+ },
+ });
+ if (result.error) throw result.error;
+ return { exitCode: result.status ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
+ },
+ /**
+ * @param {string} program
+ * @param {string[]} args
+ * @param {{ cwd?: string, env?: NodeJS.ProcessEnv }} [opts]
+ */
+ exec: async (program, args, opts = {}) => {
+ const result = spawnSync(program, args, {
+ encoding: "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}`);
+ }
+ return result.status ?? 0;
+ },
+ };
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Tests
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe("push_signed_commits integration tests", () => {
+ let bareDir;
+ let workDir;
+ let mockCore;
+ let capturedGraphQLCalls;
+
+ /** @returns {any} */
+ function makeMockGithubClient(options = {}) {
+ const { failWithError = null, oid = "signed-oid-abc123" } = options;
+ return {
+ graphql: vi.fn(async query => {
+ if (failWithError) throw failWithError;
+ capturedGraphQLCalls.push({ oid, query });
+ return { createCommitOnBranch: { commit: { oid } } };
+ }),
+ };
+ }
+
+ beforeEach(() => {
+ bareDir = createBareRepo();
+ workDir = createWorkingRepo(bareDir);
+ capturedGraphQLCalls = [];
+
+ mockCore = {
+ info: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ };
+
+ global.core = mockCore;
+ });
+
+ afterEach(() => {
+ cleanupDir(bareDir);
+ cleanupDir(workDir);
+ delete global.core;
+ delete global.exec;
+ vi.clearAllMocks();
+ });
+
+ // ──────────────────────────────────────────────────────
+ // Happy path – GraphQL succeeds
+ // ──────────────────────────────────────────────────────
+
+ describe("GraphQL signed commits (happy path)", () => {
+ it("should call GraphQL for a single new commit", async () => {
+ // Create a feature branch with one new file
+ execGit(["checkout", "-b", "feature-branch"], { cwd: workDir });
+ fs.writeFileSync(path.join(workDir, "hello.txt"), "Hello World\n");
+ execGit(["add", "hello.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Add hello.txt"], { cwd: workDir });
+ // Push the branch so ls-remote can resolve its OID
+ execGit(["push", "-u", "origin", "feature-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "feature-branch",
+ baseRef: "origin/main",
+ cwd: workDir,
+ });
+
+ expect(githubClient.graphql).toHaveBeenCalledTimes(1);
+ // Verify the mutation query targets createCommitOnBranch
+ const [query, variables] = githubClient.graphql.mock.calls[0];
+ expect(query).toContain("createCommitOnBranch");
+ expect(query).toContain("CreateCommitOnBranchInput");
+ // Verify the input structure
+ expect(variables.input.branch.branchName).toBe("feature-branch");
+ expect(variables.input.branch.repositoryNameWithOwner).toBe("test-owner/test-repo");
+ expect(variables.input.message.headline).toBe("Add hello.txt");
+ // hello.txt should appear in additions with base64 content
+ expect(variables.input.fileChanges.additions).toHaveLength(1);
+ expect(variables.input.fileChanges.additions[0].path).toBe("hello.txt");
+ expect(Buffer.from(variables.input.fileChanges.additions[0].contents, "base64").toString()).toBe("Hello World\n");
+ });
+
+ it("should call GraphQL once per commit for multiple new commits", async () => {
+ execGit(["checkout", "-b", "multi-commit-branch"], { cwd: workDir });
+
+ fs.writeFileSync(path.join(workDir, "file-a.txt"), "File A\n");
+ execGit(["add", "file-a.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Add file-a.txt"], { cwd: workDir });
+
+ fs.writeFileSync(path.join(workDir, "file-b.txt"), "File B\n");
+ execGit(["add", "file-b.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Add file-b.txt"], { cwd: workDir });
+
+ execGit(["push", "-u", "origin", "multi-commit-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "multi-commit-branch",
+ baseRef: "origin/main",
+ cwd: workDir,
+ });
+
+ expect(githubClient.graphql).toHaveBeenCalledTimes(2);
+ const headlines = githubClient.graphql.mock.calls.map(c => c[1].input.message.headline);
+ expect(headlines).toEqual(["Add file-a.txt", "Add file-b.txt"]);
+ });
+
+ it("should include deletions when files are removed in a commit", async () => {
+ execGit(["checkout", "-b", "delete-branch"], { cwd: workDir });
+
+ // First add a file, push, then delete it
+ fs.writeFileSync(path.join(workDir, "to-delete.txt"), "Will be deleted\n");
+ execGit(["add", "to-delete.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Add file to delete"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "delete-branch"], { cwd: workDir });
+
+ // Now delete the file
+ fs.unlinkSync(path.join(workDir, "to-delete.txt"));
+ execGit(["add", "-u"], { cwd: workDir });
+ execGit(["commit", "-m", "Delete file"], { cwd: workDir });
+ execGit(["push", "origin", "delete-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "delete-branch",
+ // Only replay the delete commit
+ baseRef: "delete-branch^",
+ cwd: workDir,
+ });
+
+ expect(githubClient.graphql).toHaveBeenCalledTimes(1);
+ const callArg = githubClient.graphql.mock.calls[0][1].input;
+ expect(callArg.fileChanges.deletions).toEqual([{ path: "to-delete.txt" }]);
+ expect(callArg.fileChanges.additions).toHaveLength(0);
+ });
+
+ it("should handle commit with no file changes (empty commit)", async () => {
+ execGit(["checkout", "-b", "empty-diff-branch"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "empty-diff-branch"], { cwd: workDir });
+
+ // Allow an empty commit
+ execGit(["commit", "--allow-empty", "-m", "Empty commit"], { cwd: workDir });
+ execGit(["push", "origin", "empty-diff-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "empty-diff-branch",
+ baseRef: "origin/main",
+ cwd: workDir,
+ });
+
+ expect(githubClient.graphql).toHaveBeenCalledTimes(1);
+ const callArg = githubClient.graphql.mock.calls[0][1].input;
+ expect(callArg.fileChanges.additions).toHaveLength(0);
+ expect(callArg.fileChanges.deletions).toHaveLength(0);
+ });
+
+ it("should do nothing when there are no new commits", async () => {
+ execGit(["checkout", "-b", "no-commits-branch"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "no-commits-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ // baseRef points to the same HEAD – no commits to replay
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "no-commits-branch",
+ baseRef: "origin/no-commits-branch",
+ cwd: workDir,
+ });
+
+ expect(githubClient.graphql).not.toHaveBeenCalled();
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("no new commits"));
+ });
+ });
+
+ // ──────────────────────────────────────────────────────
+ // Fallback path – GraphQL fails → git push
+ // ──────────────────────────────────────────────────────
+
+ describe("git push fallback when GraphQL fails", () => {
+ 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");
+ execGit(["add", "fallback.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Fallback commit"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "fallback-branch"], { cwd: workDir });
+
+ // Add another commit that will be pushed via git push fallback
+ fs.writeFileSync(path.join(workDir, "extra.txt"), "Extra content\n");
+ execGit(["add", "extra.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Extra commit"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient({ failWithError: new Error("GraphQL: not supported on GHES") });
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "fallback-branch",
+ baseRef: "origin/fallback-branch",
+ cwd: workDir,
+ });
+
+ // Should warn and fall back
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("falling back to git push"));
+
+ // The commit should now be on the remote (verified via ls-remote)
+ const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/fallback-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);
+ });
+ });
+
+ // ──────────────────────────────────────────────────────
+ // Commit message – multi-line body
+ // ──────────────────────────────────────────────────────
+
+ describe("commit message handling", () => {
+ it("should include the commit body when present", async () => {
+ execGit(["checkout", "-b", "body-branch"], { cwd: workDir });
+ fs.writeFileSync(path.join(workDir, "described.txt"), "content\n");
+ execGit(["add", "described.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Subject line\n\nDetailed body text\n\nMore details here"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "body-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "body-branch",
+ baseRef: "origin/main",
+ cwd: workDir,
+ });
+
+ const callArg = githubClient.graphql.mock.calls[0][1].input;
+ expect(callArg.message.headline).toBe("Subject line");
+ expect(callArg.message.body).toContain("Detailed body text");
+ });
+
+ it("should omit the body field when commit message has no body", async () => {
+ execGit(["checkout", "-b", "no-body-branch"], { cwd: workDir });
+ fs.writeFileSync(path.join(workDir, "nodesc.txt"), "nodesc\n");
+ execGit(["add", "nodesc.txt"], { cwd: workDir });
+ execGit(["commit", "-m", "Subject only"], { cwd: workDir });
+ execGit(["push", "-u", "origin", "no-body-branch"], { cwd: workDir });
+
+ global.exec = makeRealExec(workDir);
+ const githubClient = makeMockGithubClient();
+
+ await pushSignedCommits({
+ githubClient,
+ owner: "test-owner",
+ repo: "test-repo",
+ branch: "no-body-branch",
+ baseRef: "origin/main",
+ cwd: workDir,
+ });
+
+ const callArg = githubClient.graphql.mock.calls[0][1].input;
+ expect(callArg.message.headline).toBe("Subject only");
+ expect(callArg.message.body).toBeUndefined();
+ });
+ });
+});
diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs
index 9648bdc50be..9a63db223df 100644
--- a/actions/setup/js/push_to_pull_request_branch.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.cjs
@@ -4,6 +4,7 @@
/** @type {typeof import("fs")} */
const fs = require("fs");
const { generateStagedPreview } = require("./staged_preview.cjs");
+const { pushSignedCommits } = require("./push_signed_commits.cjs");
const { updateActivationCommentWithCommit, updateActivationComment } = require("./update_activation_comment.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
@@ -481,10 +482,16 @@ async function main(config = {}) {
return { success: false, error: "Failed to apply patch" };
}
- // Push the applied commits to the branch (outside patch try/catch so push failures are not misattributed)
+ // Push the applied commits to the branch using signed GraphQL commits (outside patch try/catch so push failures are not misattributed)
try {
- await exec.exec("git", ["push", "origin", branchName], {
- env: { ...process.env, ...gitAuthEnv },
+ await pushSignedCommits({
+ githubClient,
+ owner: repoParts.owner,
+ repo: repoParts.repo,
+ branch: branchName,
+ baseRef: remoteHeadBeforePatch || `origin/${branchName}`,
+ cwd: process.cwd(),
+ gitAuthEnv,
});
core.info(`Changes committed and pushed to branch: ${branchName}`);
} catch (pushError) {
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 74351e141f1..6c436211714 100644
--- a/actions/setup/js/push_to_pull_request_branch.test.cjs
+++ b/actions/setup/js/push_to_pull_request_branch.test.cjs
@@ -596,16 +596,30 @@ index 0000000..abc1234
mockExec.exec.mockResolvedValueOnce(0); // rev-parse
mockExec.exec.mockResolvedValueOnce(0); // checkout
- mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" });
+ mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "before-sha\n", stderr: "" }); // git rev-parse HEAD (before patch)
mockExec.exec.mockResolvedValueOnce(0); // git am
+
+ // pushSignedCommits: git rev-list returns one SHA so the push is attempted
+ mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" }); // git rev-list
+ // pushSignedCommits: git ls-remote returns remote HEAD OID
+ mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "remote-oid\trefs/heads/feature-branch\n", stderr: "" }); // git ls-remote
+ // pushSignedCommits: git log -1 returns commit message
+ mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "Test commit\n", stderr: "" }); // git log -1
+ // pushSignedCommits: git diff --name-status returns file changes
+ mockExec.getExecOutput.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // git diff --name-status (empty - no files)
+
+ // GraphQL call fails, triggering fallback to git push
+ mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error: branch protection"));
+
+ // Fallback git push also fails with non-fast-forward
mockExec.exec.mockRejectedValueOnce(new Error("! [rejected] feature-branch -> feature-branch (non-fast-forward)"));
const module = await loadModule();
const handler = await module.main({});
const result = await handler({ patch_path: patchPath }, {});
- // The error happens during push, which currently shows in patch apply failure
+ // The error happens during push
expect(result.success).toBe(false);
});
diff --git a/go.mod b/go.mod
index 4e7e9a0d876..0baf5dc664f 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.2
github.com/charmbracelet/huh v0.8.0
+ github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc
github.com/charmbracelet/x/exp/golden v0.0.0-20251215102626-e0db08df7383
github.com/cli/go-gh/v2 v2.13.0
github.com/creack/pty v1.1.24
@@ -42,7 +43,6 @@ require (
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
- github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect