Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
42eba58
Initial plan
Copilot May 15, 2026
82101fe
fix: allow unsigned PR branch pushes when configured
Copilot May 15, 2026
115dd07
Plan signed commits feedback
Copilot May 15, 2026
056d58c
Restore linter miner lockfile
Copilot May 15, 2026
1256302
Apply signed commits option feedback
Copilot May 15, 2026
b40bd5d
Keep branch prefix warning readable
Copilot May 15, 2026
21922b1
Merge remote-tracking branch 'origin/main' into copilot/push-to-pull-…
Copilot May 15, 2026
584be01
Recompile workflows after main merge
Copilot May 15, 2026
d9799de
Apply SOLID cleanup to unsigned push path
Copilot May 15, 2026
23e041f
Address SOLID validation feedback
Copilot May 15, 2026
947aa41
Clarify signed commit opt-out naming
Copilot May 15, 2026
6569dc1
Shorten signed commits predicate name
Copilot May 15, 2026
72c1ae8
Clarify signed commit opt-out log
Copilot May 15, 2026
70ffc73
Document unsigned push HEAD resolution
Copilot May 15, 2026
cf4309f
Simplify unsigned push logging
Copilot May 15, 2026
885fa6f
Consolidate unsigned push documentation
Copilot May 15, 2026
a5b4bf9
Clarify direct push helper docs
Copilot May 15, 2026
f006c79
Combine direct push head resolution
Copilot May 15, 2026
dfdd575
Prioritize unsigned push opt-out
Copilot May 15, 2026
eb58f06
Clarify signed push helper docs
Copilot May 15, 2026
ee10dbb
Tighten unsigned push wording
Copilot May 15, 2026
73b13b0
Align unsigned push test logging
Copilot May 15, 2026
a39a17c
Fix JS lint formatting
Copilot May 15, 2026
5637a74
Merge remote-tracking branch 'origin/main' into copilot/push-to-pull-…
Copilot May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-push-signed-commits-opt-out.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
Expand All @@ -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;
Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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)");

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -1780,6 +1787,7 @@ ${patchPreview}`;
branch: branchName,
baseRef: `origin/${baseBranch}`,
cwd: process.cwd(),
signedCommits,
});
core.info("Empty branch pushed successfully");

Expand Down
31 changes: 31 additions & 0 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <test@example.com>
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(
Expand Down
64 changes: 43 additions & 21 deletions actions/setup/js/push_signed_commits.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* @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
Expand Down Expand Up @@ -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<string>}
*/
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<string>}
*/
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
Expand All @@ -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<string | undefined>} 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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 33 additions & 0 deletions actions/setup/js/push_signed_commits.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -891,6 +893,7 @@ async function main(config = {}) {
baseRef: remoteHeadBeforePatch || `origin/${branchName}`,
cwd: repoCwd || process.cwd(),
gitAuthEnv,
signedCommits,
});
if (pushedSha) {
pushedCommitSha = pushedSha;
Expand Down
7 changes: 7 additions & 0 deletions actions/setup/js/push_to_pull_request_branch.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/types/handler-factory.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion actions/setup/js/update_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
10 changes: 10 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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."
Expand Down
Loading