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