Analysis
pushSignedCommits correctly detects four commit shapes that GraphQL createCommitOnBranch cannot represent (merge commit, submodule change, symlink mode 120000, executable mode 100755) and throws for them in pre-flight checks. The catch block then unconditionally falls back to a plain git push origin <branch>, which produces unsigned commits.
On any repository with the "Commits must have verified signatures" branch protection rule, that fallback is rejected with GH013 — but only after the action has decided that an unsigned push is acceptable. The user has explicitly said "no unsigned commits ever"; the action quietly tries one anyway. The push fails (correctly), so the workflow's safe output is unusable; from the user's side it looks like "the action attempted to violate my policy."
The bug is in actions/setup/js/push_signed_commits.cjs and is verifiable by inspection alone:
- Lines 166-177 — pre-flight merge-commit detection that throws
"merge commit detected".
- Lines 224-278 — similar pre-flight throws for submodule (mode
160000), symlink (mode 120000), and executable-mode (100755) changes (delete, rename, copy, and modify cases).
- Lines 382-391 — the
catch block that unconditionally runs git push origin <branch> regardless of whether the original error is a fixable transient GraphQL failure or one of the four pre-flight refusals that cannot be signed at all.
The corresponding test in actions/setup/js/push_signed_commits.test.cjs currently codifies the broken behavior:
describe("git push fallback when GraphQL fails")
describe("merge commit fallback")
it("should fall back to git push and warn when the commit range contains a merge commit", …)
The behavior is identical from v0.71.5 through v0.72.1, the current latest pre-release v0.74.0, and main (verified by comparing blob SHAs of push_signed_commits.cjs across each tag — the only inter-version delta is an unrelated empty-baseRef early-return added in v0.71.6).
I encountered this on an internal repository running a pr-catchup-style workflow that uses push-to-pull-request-branch with patch-format: bundle and deliberately produces a two-parent merge commit (so the resulting PR head has origin/main as an ancestor, which is what makes the GitHub UI consider the branch caught up). The merge-commit shape is intentional and not a bug in our workflow; it's the documented way to keep PR branches current with main via the safe output. Happy to share concrete artifacts with a maintainer privately.
Implementation Plan
Please implement the following changes:
-
Introduce a sentinel for "structurally unsignable" errors in actions/setup/js/push_signed_commits.cjs:
- Add a top-level
class PushSignedCommitsUnsupportedShape extends Error {} (or attach err.code = "UNSUPPORTED_SHAPE") so the catch block can distinguish pre-flight refusals from transient GraphQL failures.
- Replace each of the eight existing
throw new Error(...) sites in pre-flight checks (lines 175, 228, 241, 245, 260, 264, 274, 278 in the current main) with throw new PushSignedCommitsUnsupportedShape(...). Keep the core.warning(...) call ahead of each throw so the log output retains today's diagnostic value.
-
Branch the catch block at lines 382-391 to refuse the unsigned fallback for these four shapes:
-
If err instanceof PushSignedCommitsUnsupportedShape (or err.code === "UNSUPPORTED_SHAPE"), re-throw with a single consolidated error message and do not invoke git push. Error string follows the error-messages skill template [what's wrong]. [what's expected]. [example]:
pushSignedCommits: refusing to push <shape> as an unsigned commit. Expected a commit series GraphQL createCommitOnBranch can represent (single-parent, no submodules, no symlinks, no executable-mode bits) so the resulting commits are GitHub-signed. Example: rebase or squash the series to flatten the merge before invoking the safe output, or use a workflow primitive that produces a signed merge server-side.
where <shape> is one of merge commit, submodule change, symlink (mode 120000), executable-mode change (100755). A small formatShape(err) helper can map the sentinel's payload to the human string.
-
For any other thrown error (transient GraphQL failure, network glitch, rate limit), keep today's behavior: warn and fall back to git push origin <branch>. Document this distinction with a comment so the next reader doesn't reintroduce the silent fallback.
-
Update the existing test in actions/setup/js/push_signed_commits.test.cjs:
- Rename
describe("merge commit fallback") to describe("merge commit refusal") and rewrite its single it(...) to assert that pushSignedCommits rejects (or throws) the PushSignedCommitsUnsupportedShape sentinel, that the rejection message matches the format above, and that the git push mock is not called. The current expectation that git push runs becomes the regression we are preventing.
-
Add three new tests in the same file covering the other pre-flight shapes — each asserting refusal-not-fallback:
it("should refuse and throw UNSUPPORTED_SHAPE when a commit modifies a submodule (mode 160000)", …) — exercise the delete, rename, and copy submodule paths so all three pre-flight throw sites are covered.
it("should refuse and throw UNSUPPORTED_SHAPE when a commit adds or renames a symlink (mode 120000)", …)
it("should refuse and throw UNSUPPORTED_SHAPE when a commit changes file mode to executable (100755)", …)
-
Add one negative test confirming transient GraphQL failures still fall back — it("should fall back to git push when GraphQL fails with a non-UNSUPPORTED_SHAPE error", …) — to guard against an over-eager future refactor collapsing both branches.
-
Follow guidelines:
- Run
make agent-finish to build, recompile, format, and run the JS test suite before completing.
- Use the error-messages skill format for the new error string.
- Keep the change scoped to
push_signed_commits.cjs and its test file — no schema, frontmatter, or compiler changes are needed for this minimum fix.
Why this fix and not a deeper one
A deeper fix would use GitHub's GraphQL mergeBranch mutation to perform the merge server-side for the merge-commit case (the mutation produces a signed two-parent merge commit, which is exactly what push-to-pull-request-branch users with patch-format: bundle are trying to express). That is the right long-term direction and I'm happy to file it as a separate issue, but it's a meaningful design change with implications for how push-to-pull-request-branch resolves "what ref am I merging from" given only the local merge commit's parents. The plan above is the smallest change that stops silently violating signed-commits policy and turns a daily-failing CI signal into a single clear failure that the workflow author can act on.
Analysis
pushSignedCommitscorrectly detects four commit shapes that GraphQLcreateCommitOnBranchcannot represent (merge commit, submodule change, symlink mode120000, executable mode100755) andthrows for them in pre-flight checks. Thecatchblock then unconditionally falls back to a plaingit push origin <branch>, which produces unsigned commits.On any repository with the "Commits must have verified signatures" branch protection rule, that fallback is rejected with
GH013— but only after the action has decided that an unsigned push is acceptable. The user has explicitly said "no unsigned commits ever"; the action quietly tries one anyway. The push fails (correctly), so the workflow's safe output is unusable; from the user's side it looks like "the action attempted to violate my policy."The bug is in
actions/setup/js/push_signed_commits.cjsand is verifiable by inspection alone:"merge commit detected".160000), symlink (mode120000), and executable-mode (100755) changes (delete, rename, copy, and modify cases).catchblock that unconditionally runsgit push origin <branch>regardless of whether the original error is a fixable transient GraphQL failure or one of the four pre-flight refusals that cannot be signed at all.The corresponding test in
actions/setup/js/push_signed_commits.test.cjscurrently codifies the broken behavior:The behavior is identical from
v0.71.5throughv0.72.1, the current latest pre-releasev0.74.0, andmain(verified by comparing blob SHAs ofpush_signed_commits.cjsacross each tag — the only inter-version delta is an unrelated empty-baseRefearly-return added inv0.71.6).I encountered this on an internal repository running a pr-catchup-style workflow that uses
push-to-pull-request-branchwithpatch-format: bundleand deliberately produces a two-parent merge commit (so the resulting PR head hasorigin/mainas an ancestor, which is what makes the GitHub UI consider the branch caught up). The merge-commit shape is intentional and not a bug in our workflow; it's the documented way to keep PR branches current withmainvia the safe output. Happy to share concrete artifacts with a maintainer privately.Implementation Plan
Please implement the following changes:
Introduce a sentinel for "structurally unsignable" errors in
actions/setup/js/push_signed_commits.cjs:class PushSignedCommitsUnsupportedShape extends Error {}(or attacherr.code = "UNSUPPORTED_SHAPE") so the catch block can distinguish pre-flight refusals from transient GraphQL failures.throw new Error(...)sites in pre-flight checks (lines 175, 228, 241, 245, 260, 264, 274, 278 in the currentmain) withthrow new PushSignedCommitsUnsupportedShape(...). Keep thecore.warning(...)call ahead of eachthrowso the log output retains today's diagnostic value.Branch the catch block at lines 382-391 to refuse the unsigned fallback for these four shapes:
If
err instanceof PushSignedCommitsUnsupportedShape(orerr.code === "UNSUPPORTED_SHAPE"), re-throw with a single consolidated error message and do not invokegit push. Error string follows the error-messages skill template[what's wrong]. [what's expected]. [example]:where
<shape>is one ofmerge commit,submodule change,symlink (mode 120000),executable-mode change (100755). A smallformatShape(err)helper can map the sentinel's payload to the human string.For any other thrown error (transient GraphQL failure, network glitch, rate limit), keep today's behavior: warn and fall back to
git push origin <branch>. Document this distinction with a comment so the next reader doesn't reintroduce the silent fallback.Update the existing test in
actions/setup/js/push_signed_commits.test.cjs:describe("merge commit fallback")todescribe("merge commit refusal")and rewrite its singleit(...)to assert thatpushSignedCommitsrejects (orthrows) thePushSignedCommitsUnsupportedShapesentinel, that the rejection message matches the format above, and that thegit pushmock is not called. The current expectation thatgit pushruns becomes the regression we are preventing.Add three new tests in the same file covering the other pre-flight shapes — each asserting refusal-not-fallback:
it("should refuse and throw UNSUPPORTED_SHAPE when a commit modifies a submodule (mode 160000)", …)— exercise the delete, rename, and copy submodule paths so all three pre-flight throw sites are covered.it("should refuse and throw UNSUPPORTED_SHAPE when a commit adds or renames a symlink (mode 120000)", …)it("should refuse and throw UNSUPPORTED_SHAPE when a commit changes file mode to executable (100755)", …)Add one negative test confirming transient GraphQL failures still fall back —
it("should fall back to git push when GraphQL fails with a non-UNSUPPORTED_SHAPE error", …)— to guard against an over-eager future refactor collapsing both branches.Follow guidelines:
make agent-finishto build, recompile, format, and run the JS test suite before completing.push_signed_commits.cjsand its test file — no schema, frontmatter, or compiler changes are needed for this minimum fix.Why this fix and not a deeper one
A deeper fix would use GitHub's GraphQL
mergeBranchmutation to perform the merge server-side for the merge-commit case (the mutation produces a signed two-parent merge commit, which is exactly whatpush-to-pull-request-branchusers withpatch-format: bundleare trying to express). That is the right long-term direction and I'm happy to file it as a separate issue, but it's a meaningful design change with implications for howpush-to-pull-request-branchresolves "what ref am I merging from" given only the local merge commit's parents. The plan above is the smallest change that stops silently violating signed-commits policy and turns a daily-failing CI signal into a single clear failure that the workflow author can act on.