Thanks to visit codestin.com
Credit goes to github.com

Skip to content

pushSignedCommits silently falls back to unsigned git push on merge / symlink / submodule / exec-bit commits, violating "Require signed commits" #31869

@michen00

Description

@michen00

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:

  1. 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.
  2. 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.

  3. 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.
  4. 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)", …)
  5. Add one negative test confirming transient GraphQL failures still fall backit("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.

  6. 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.

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions