diff --git a/.changeset/patch-push-signed-commits-opt-out.md b/.changeset/patch-push-signed-commits-opt-out.md new file mode 100644 index 00000000000..a0692368ab7 --- /dev/null +++ b/.changeset/patch-push-signed-commits-opt-out.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Allow `create-pull-request` and `push-to-pull-request-branch` to set `signed-commits: false` so repositories that do not require signed commits can push merge commits with direct `git push`. diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 6df99d03d0d..51e8fe8f88a 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -670,9 +670,12 @@ async function main(config = {}) { const rawBranchPrefix = config.branch_prefix || ""; const normalizedBranchPrefix = normalizeBranchName(rawBranchPrefix); if (rawBranchPrefix && normalizedBranchPrefix !== rawBranchPrefix) { - core.warning( - `Branch prefix "${rawBranchPrefix}" contains characters that are invalid in a git ref. ` + `Using normalized prefix: "${normalizedBranchPrefix}". ` + `Update branch-prefix in the workflow configuration to avoid this warning.` - ); + const branchPrefixWarning = [ + `Branch prefix "${rawBranchPrefix}" contains characters that are invalid in a git ref.`, + `Using normalized prefix: "${normalizedBranchPrefix}".`, + "Update branch-prefix in the workflow configuration to avoid this warning.", + ].join(" "); + core.warning(branchPrefixWarning); } const branchPrefix = normalizedBranchPrefix; const titlePrefix = config.title_prefix || ""; @@ -689,6 +692,7 @@ async function main(config = {}) { const autoMerge = parseBoolTemplatable(config.auto_merge, false); const preserveBranchName = config.preserve_branch_name === true; const recreateRef = config.recreate_ref === true; + const signedCommits = config.signed_commits !== false; const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const maxCount = config.max || 1; // PRs are typically limited to 1 const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; @@ -820,6 +824,7 @@ async function main(config = {}) { core.info(`If no changes: ${ifNoChanges}`); core.info(`Allow empty: ${allowEmpty}`); core.info(`Auto-merge: ${autoMerge}`); + core.info(`Signed commits: ${signedCommits}`); if (expiresHours > 0) { core.info(`Pull requests expire after: ${expiresHours} hours`); } @@ -1420,6 +1425,7 @@ async function main(config = {}) { branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + signedCommits, }); core.info("Changes pushed to branch (from bundle)"); @@ -1636,6 +1642,7 @@ gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + signedCommits, }); core.info("Changes pushed to branch"); @@ -1780,6 +1787,7 @@ ${patchPreview}`; branch: branchName, baseRef: `origin/${baseBranch}`, cwd: process.cwd(), + signedCommits, }); core.info("Empty branch pushed successfully"); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index d4ba252452a..a425e536bde 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -266,6 +266,37 @@ index 0000000..abc1234 expect(bundleFetchCallIndex).toBeGreaterThan(unshallowCallIndex); }); + it("should pass signed_commits false to bundle pushes", async () => { + const patchPath = path.join(tempDir, "test.patch"); + fs.writeFileSync( + patchPath, + `From abc123 Mon Sep 17 00:00:00 2001 +From: Test Author +Date: Mon, 1 Jan 2024 00:00:00 +0000 +Subject: [PATCH] Test commit + +diff --git a/test.txt b/test.txt +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/test.txt +@@ -0,0 +1 @@ ++Hello World +-- +2.34.1 +` + ); + const bundlePath = path.join(tempDir, "test.bundle"); + fs.writeFileSync(bundlePath, "bundle content"); + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ base_branch: "main", preserve_branch_name: true, signed_commits: false }); + const result = await handler({ title: "Test PR", body: "Test body", branch: "feature/test", patch_path: patchPath, bundle_path: bundlePath }, {}); + + expect(result.success).toBe(true); + expect(pushSignedSpy).toHaveBeenCalledWith(expect.objectContaining({ signedCommits: false })); + }); + it("should resolve bundle source ref from list-heads when JSONL branch ref is missing in bundle", async () => { const patchPath = path.join(tempDir, "test.patch"); fs.writeFileSync( diff --git a/actions/setup/js/push_signed_commits.cjs b/actions/setup/js/push_signed_commits.cjs index 4df9fffc332..b87caa43b5b 100644 --- a/actions/setup/js/push_signed_commits.cjs +++ b/actions/setup/js/push_signed_commits.cjs @@ -1,6 +1,12 @@ // @ts-check /// +/** + * @fileoverview Shared helper for pushing local commits either through + * GitHub's signed-commit GraphQL API or, when explicitly configured, direct + * `git push`. + */ + const { ERR_API } = require("./error_codes.cjs"); /** Sentinel error class used to signal that the commit range contains a shape @@ -119,17 +125,33 @@ async function readBlobAsBase64(blobHash, cwd) { } /** - * @fileoverview Signed Commit Push Helper + * Push the local branch to origin using git directly and return the local HEAD + * SHA after the push succeeds. * - * 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). + * @param {object} opts + * @param {string} opts.branch + * @param {string} opts.cwd + * @param {object} [opts.gitAuthEnv] + * @returns {Promise} + */ +async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }) { + await exec.exec("git", ["push", "origin", branch], { + cwd, + env: { ...process.env, ...(gitAuthEnv || {}) }, + }); + return resolveLocalHeadSha(cwd); +} + +/** + * Resolve the local HEAD SHA. * - * 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. + * @param {string} cwd + * @returns {Promise} */ +async function resolveLocalHeadSha(cwd) { + const { stdout } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], { cwd }); + return stdout.trim(); +} /** * Pushes local commits to a remote branch using the GitHub GraphQL @@ -144,21 +166,25 @@ async function readBlobAsBase64(blobHash, cwd) { * @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 + * @param {boolean} [opts.signedCommits=true] - When false, skip GraphQL signed commits and use git push directly * @returns {Promise} SHA of the commit that landed on the target branch */ -async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv }) { +async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv, signedCommits = true }) { + // The default parameter value converts undefined to true; this check tests only the explicit false value. + if (signedCommits === false) { + core.info(`pushSignedCommits: signed-commits disabled (using direct git push) for branch ${branch}`); + const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); + core.info(`pushSignedCommits: git push and HEAD resolution completed, HEAD=${headSha}`); + return headSha; + } + // Orphan branch first push: baseRef is "" when push_experiment_state creates a brand-new // branch for the first time (checkoutOrCreateBranch returns "" for new branches). // The GraphQL createCommitOnBranch path cannot handle root commits (no parent to resolve), // so skip it entirely and fall directly through to git push. if (!baseRef) { core.info(`pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch ${branch}`); - await exec.exec("git", ["push", "origin", branch], { - cwd, - env: { ...process.env, ...(gitAuthEnv || {}) }, - }); - const { stdout: headOut } = await exec.getExecOutput("git", ["rev-parse", "HEAD"], { cwd }); - const headSha = headOut.trim(); + const headSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); core.info(`pushSignedCommits: git push completed for orphan branch, HEAD=${headSha}`); return headSha; } @@ -402,16 +428,12 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c `GitHub's createCommitOnBranch GraphQL mutation cannot represent merge commits, symlinks (mode 120000), ` + `submodule entries (mode 160000), or executable bits (mode 100755). ` + `Rewrite the commits to use only regular files (mode 100644) with no merge commits, ` + - `or set push-signed-commits: false if the repository does not require signed commits.`, + `or set signed-commits: false if the repository does not require signed commits.`, { cause: err } ); } core.warning(`pushSignedCommits: GraphQL signed push failed, falling back to git push: ${err instanceof Error ? err.message : String(err)}`); - await exec.exec("git", ["push", "origin", branch], { - cwd, - env: { ...process.env, ...(gitAuthEnv || {}) }, - }); - const fallbackSha = shas[shas.length - 1]; + const fallbackSha = await pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }); core.info(`pushSignedCommits: git push fallback completed, using pushed SHA ${fallbackSha}`); return fallbackSha; } diff --git a/actions/setup/js/push_signed_commits.test.cjs b/actions/setup/js/push_signed_commits.test.cjs index 36c686707a1..3faf33a376b 100644 --- a/actions/setup/js/push_signed_commits.test.cjs +++ b/actions/setup/js/push_signed_commits.test.cjs @@ -1126,6 +1126,39 @@ describe("push_signed_commits integration tests", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringMatching(/merge commit [0-9a-f]{7,40} detected/)); }); + it("should use direct git push for merge commits when signed commits are disabled", async () => { + execGit(["checkout", "-b", "unsigned-side-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "unsigned-side.txt"), "side branch content\n"); + execGit(["add", "unsigned-side.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Unsigned side branch commit"], { cwd: workDir }); + + execGit(["checkout", "main"], { cwd: workDir }); + execGit(["checkout", "-b", "unsigned-merge-test-branch"], { cwd: workDir }); + fs.writeFileSync(path.join(workDir, "unsigned-feature.txt"), "feature content\n"); + execGit(["add", "unsigned-feature.txt"], { cwd: workDir }); + execGit(["commit", "-m", "Unsigned feature commit"], { cwd: workDir }); + execGit(["merge", "--no-ff", "unsigned-side-branch", "-m", "Merge unsigned-side-branch into unsigned-merge-test-branch"], { cwd: workDir }); + const expectedHead = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim(); + + global.exec = makeRealExec(workDir); + const githubClient = makeMockGithubClient(); + + const pushedSha = await pushSignedCommits({ + githubClient, + owner: "test-owner", + repo: "test-repo", + branch: "unsigned-merge-test-branch", + baseRef: "origin/main", + cwd: workDir, + signedCommits: false, + }); + + expect(pushedSha).toBe(expectedHead); + expect(githubClient.graphql).not.toHaveBeenCalled(); + expect(execGit(["rev-parse", "refs/heads/unsigned-merge-test-branch"], { cwd: bareDir }).stdout.trim()).toBe(expectedHead); + expect(mockCore.info).toHaveBeenCalledWith("pushSignedCommits: signed-commits disabled (using direct git push) for branch unsigned-merge-test-branch"); + }); + it("should not trigger merge-commit fallback for a commit message that starts with 'parent '", async () => { // Regression test: a commit whose message body starts with "parent " must not be misidentified // as a merge commit. The old cat-file approach would have counted this as an extra parent. diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 80f4e6a9c9b..e5cb08ff87c 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -113,6 +113,7 @@ async function main(config = {}) { const ignoreMissingBranchFailure = config.ignore_missing_branch_failure === true; const fallbackAsPullRequest = config.fallback_as_pull_request !== false; const checkBranchProtection = config.check_branch_protection !== false; + const signedCommits = config.signed_commits !== false; const commitTitleSuffix = config.commit_title_suffix || ""; const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; const maxCount = config.max || 0; // 0 means no limit @@ -150,6 +151,7 @@ async function main(config = {}) { core.info(`Ignore missing branch failure: ${ignoreMissingBranchFailure}`); core.info(`Fallback as pull request: ${fallbackAsPullRequest}`); core.info(`Check branch protection: ${checkBranchProtection}`); + core.info(`Push signed commits: ${signedCommits}`); if (commitTitleSuffix) { core.info(`Commit title suffix: ${commitTitleSuffix}`); } @@ -891,6 +893,7 @@ async function main(config = {}) { baseRef: remoteHeadBeforePatch || `origin/${branchName}`, cwd: repoCwd || process.cwd(), gitAuthEnv, + signedCommits, }); if (pushedSha) { pushedCommitSha = pushedSha; 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 7d26526bb3b..ec507d09a1a 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -257,6 +257,13 @@ index 0000000..abc1234 expect(mockCore.info).toHaveBeenCalledWith("Target: 456"); }); + it("should accept disabling signed commits", async () => { + const module = await loadModule(); + await module.main({ signed_commits: false }); + + expect(mockCore.info).toHaveBeenCalledWith("Push signed commits: false"); + }); + it('should default if_no_changes to "warn"', async () => { const module = await loadModule(); const handler = await module.main({}); diff --git a/actions/setup/js/types/handler-factory.d.ts b/actions/setup/js/types/handler-factory.d.ts index 6ae98df8c2c..b98e602bb8c 100644 --- a/actions/setup/js/types/handler-factory.d.ts +++ b/actions/setup/js/types/handler-factory.d.ts @@ -24,6 +24,8 @@ interface HandlerConfig { protected_files_policy?: string; /** When true (default), create a fallback pull request if direct push to PR branch fails with non-fast-forward/diverged branch. */ fallback_as_pull_request?: boolean; + /** When false, skip GraphQL signed commits and push the local git history directly. */ + signed_commits?: boolean; /** Additional handler-specific configuration properties */ [key: string]: any; } diff --git a/actions/setup/js/update_pull_request.cjs b/actions/setup/js/update_pull_request.cjs index 3da5866e153..adac21ada2c 100644 --- a/actions/setup/js/update_pull_request.cjs +++ b/actions/setup/js/update_pull_request.cjs @@ -26,7 +26,10 @@ function isNonFatalUpdateBranchError(error) { /** @type {number | undefined} */ let status; if (typeof error === "object" && error !== null && "status" in error) { - status = /** @type {{status?: number}} */ (error).status; + const candidateStatus = error.status; + if (typeof candidateStatus === "number") { + status = candidateStatus; + } } if (status !== undefined && status !== 422) { return false; diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index f07fcaee013..c2456c79568 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -54,6 +54,7 @@ safe-outputs: - "dist/**" github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # optional token to push empty commit triggering CI + signed-commits: true # signed commits are required (default); set false to use git push directly protected-files: fallback-to-issue # push branch, create review issue if protected files modified ``` @@ -264,6 +265,7 @@ safe-outputs: github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions github-token-for-extra-empty-commit: ${{ secrets.CI_TOKEN }} # optional token to push empty commit triggering CI fallback-as-pull-request: true # on non-fast-forward failure, create fallback PR to original PR branch (default: true) + signed-commits: true # signed commits are required (default); set false to use git push directly ignore-missing-branch-failure: false # treat deleted/missing branch errors as skipped instead of failed (default: false) check-branch-protection: true # set to false to skip the branch protection pre-flight check (default: true) protected-files: fallback-to-issue # create review issue if protected files modified @@ -273,6 +275,8 @@ safe-outputs: When `push-to-pull-request-branch` is configured, git commands (`checkout`, `branch`, `switch`, `add`, `rm`, `commit`, `merge`) are automatically enabled. +By default, pushes are replayed through GitHub's signed commit API because `signed-commits: true` means signed commits are required. Set `signed-commits: false` only for repositories that do not require signed commits; this uses direct `git push` and can preserve merge commits that the signed commit API cannot represent. This field is supported by both `create-pull-request` and `push-to-pull-request-branch`. + ### Cross-repo usage `push-to-pull-request-branch` supports pushing to pull requests in a different repository via `target-repo` (and optionally `allowed-repos`). When `target-repo` is set, **the target repository must be checked out into the workflow workspace** using the `checkout:` frontmatter field with a `path:` specified. diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 9346881519d..78429101d83 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -890,6 +890,7 @@ safe-outputs: target: "*" # "triggering" (default), "*", or number title-prefix: "[bot] " # require title prefix labels: [automated] # require all labels + signed-commits: false # optional: use git push directly when signed commits are not required protected-files: fallback-to-issue # create review issue if protected files modified ``` diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3aa21bcb570..a1c1c607e4d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6382,6 +6382,11 @@ ], "description": "Transport format for packaging changes. \"bundle\" (default) uses git bundle. \"am\" uses git format-patch/git am. Accepts a GitHub Actions expression for reusable workflows." }, + "signed-commits": { + "type": "boolean", + "description": "When true (default), signed commits are required and pushes use GitHub's createCommitOnBranch GraphQL mutation so GitHub signs them. Set to false to use git push directly for repositories that do not require signed commits; this also allows pushing merge commits that GraphQL cannot represent.", + "default": true + }, "staged": { "type": "boolean", "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", @@ -7616,6 +7621,11 @@ "description": "When true (default), if pushing to the PR branch fails due to a non-fast-forward/diverged branch, create a fallback pull request that targets the original PR branch. Set to false to disable this behavior and avoid requiring pull-requests: write permission.", "default": true }, + "signed-commits": { + "type": "boolean", + "description": "When true (default), signed commits are required and pushes use GitHub's createCommitOnBranch GraphQL mutation so GitHub signs them. Set to false to use git push directly for repositories that do not require signed commits; this also allows pushing merge commits that GraphQL cannot represent.", + "default": true + }, "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository push to pull request branch. Takes precedence over trial target repo settings." diff --git a/pkg/workflow/compile_outputs_pr_test.go b/pkg/workflow/compile_outputs_pr_test.go index 5927990bae9..2830200b822 100644 --- a/pkg/workflow/compile_outputs_pr_test.go +++ b/pkg/workflow/compile_outputs_pr_test.go @@ -757,6 +757,61 @@ This workflow tests the create-pull-request with fallback-as-issue disabled. } } +func TestOutputPullRequestSignedCommitsDisabled(t *testing.T) { + tmpDir := testutil.TempDir(t, "output-pr-signed-commits-test") + + testContent := `--- +on: push +permissions: + contents: read + pull-requests: read +engine: claude +strict: false +safe-outputs: + create-pull-request: + title-prefix: "[test] " + signed-commits: false + noop: + report-as-issue: false +--- + +# Test Output Pull Request Signed Commits Disabled +` + + testFile := filepath.Join(tmpDir, "test-output-pr-signed-commits.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + workflowData, err := compiler.ParseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with signed-commits: false: %v", err) + } + if workflowData.SafeOutputs == nil || workflowData.SafeOutputs.CreatePullRequests == nil { + t.Fatal("Expected create-pull-request configuration to be parsed") + } + if workflowData.SafeOutputs.CreatePullRequests.SignedCommits == nil { + t.Fatal("Expected signed-commits to be set") + } + if *workflowData.SafeOutputs.CreatePullRequests.SignedCommits { + t.Error("Expected signed-commits to be false") + } + + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("Unexpected error compiling workflow with signed-commits: false: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(testFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + if !strings.Contains(string(lockContent), `"signed_commits":false`) { + t.Error("Expected signed_commits:false in handler config JSON") + } +} + func TestOutputPullRequestFallbackAsIssueDefault(t *testing.T) { // Create temporary directory for test files tmpDir := testutil.TempDir(t, "output-pr-fallback-default-test") diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go index 443617bf080..58b714bc297 100644 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ b/pkg/workflow/compiler_safe_outputs_handlers.go @@ -421,6 +421,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfTrue("preserve_branch_name", c.PreserveBranchName). AddIfTrue("recreate_ref", c.RecreateRef). AddIfNotEmpty("patch_format", c.PatchFormat). + AddBoolPtr("signed_commits", c.SignedCommits). AddIfTrue("staged", c.Staged) return builder.Build() }, @@ -455,6 +456,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("excluded_files", c.ExcludedFiles). AddIfNotEmpty("patch_format", c.PatchFormat). AddBoolPtr("fallback_as_pull_request", c.FallbackAsPullRequest). + AddBoolPtr("signed_commits", c.SignedCommits). AddBoolPtr("check_branch_protection", c.CheckBranchProtection). Build() }, diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index c5901af8834..c4be15299bc 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -45,6 +45,7 @@ type CreatePullRequestsConfig struct { PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). + SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. } diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index 0afa15f450f..046a8030bb1 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -27,6 +27,7 @@ type PushToPullRequestBranchConfig struct { ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). FallbackAsPullRequest *bool `yaml:"fallback-as-pull-request,omitempty"` // When true (default), creates a fallback pull request if direct push fails due to diverged/non-fast-forward branch. When false, fallback is disabled and pull-requests: write is not requested. + SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. CheckBranchProtection *bool `yaml:"check-branch-protection,omitempty"` // When false, skips the branch protection API pre-flight check. Default is true (check enabled). Set to false to avoid needing administration: read permission. } @@ -182,6 +183,13 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) } } + // Parse signed-commits (optional, defaults to true) + if signedCommits, exists := configMap["signed-commits"]; exists { + if signedCommitsBool, ok := signedCommits.(bool); ok { + pushToBranchConfig.SignedCommits = &signedCommitsBool + } + } + // Parse allow-workflows: when true, adds workflows: write to the GitHub App token if allowWorkflows, exists := configMap["allow-workflows"]; exists { if allowWorkflowsBool, ok := allowWorkflows.(bool); ok { diff --git a/pkg/workflow/push_to_pull_request_branch_test.go b/pkg/workflow/push_to_pull_request_branch_test.go index 3b0a8e6c2d3..d26ddf45d50 100644 --- a/pkg/workflow/push_to_pull_request_branch_test.go +++ b/pkg/workflow/push_to_pull_request_branch_test.go @@ -241,6 +241,51 @@ safe-outputs: } } +func TestPushToPullRequestBranchSignedCommitsDisabled(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-*") + + testMarkdown := `--- +on: + pull_request: + types: [opened, synchronize] +safe-outputs: + push-to-pull-request-branch: + signed-commits: false +--- + +# Test Push to PR Branch Signed Commits Disabled +` + + mdFile := filepath.Join(tmpDir, "test-push-to-pull-request-branch-signed-commits-disabled.md") + if err := os.WriteFile(mdFile, []byte(testMarkdown), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(mdFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(mdFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + pushConfig := extractPushToPullRequestBranchHandlerConfig(t, lockContent) + signedCommits, exists := pushConfig["signed_commits"] + if !exists { + t.Errorf("Generated workflow should contain signed_commits in handler config JSON") + } + signedCommitsBool, isBool := signedCommits.(bool) + if !isBool { + t.Errorf("Expected signed_commits to be a bool, got %#v", signedCommits) + } + if signedCommitsBool { + t.Errorf("Expected signed_commits=false, got %#v", signedCommitsBool) + } +} + func TestPushToPullRequestBranchFallbackAsPullRequestEnabled(t *testing.T) { tmpDir := testutil.TempDir(t, "test-*")