fix: apply signed-merge checks by merge style (#11403)

Fixes #6438

When a protected branch requires signed commits and no signing key is available, fast-forward-only merges should still be allowed because they do not create a new commit.

This patch applies signing checks by merge behaviour/style instead of one global gate:

- pass `mergeStyle` through `CheckPullMergeable(...)` in web/API/automerge paths
- require signing for commit-creating styles (`merge`, `rebase`, `rebase-merge`, `squash`)
- bypass signing precheck only for `fast-forward-only`
- align merge UI options with backend behaviour so signing-dependent styles are unavailable when signing cannot happen
- add Go unit tests for merge-style signing requirements
- add frontend unit coverage for the no-allowed-merge-styles guard

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11403
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: abdo <dev@abdo.wtf>
Co-committed-by: abdo <dev@abdo.wtf>
This commit is contained in:
abdo 2026-04-09 20:26:27 +02:00 committed by Gusted
parent 4e6a782a89
commit e16dc2ebfd
8 changed files with 129 additions and 25 deletions

View file

@ -215,7 +215,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, false); err != nil {
if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, false, scheduledPRM.MergeStyle); err != nil {
if errors.Is(err, pull_service.ErrUserNotAllowedToMerge) {
log.Info("%-v was scheduled to automerge by an unauthorized user", pr)
return

View file

@ -65,7 +65,7 @@ const (
)
// CheckPullMergeable check if the pull mergeable based on all conditions (branch protection, merge options, ...)
func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, adminSkipProtectionCheck bool) error {
func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, adminSkipProtectionCheck bool, mergeStyle repo_model.MergeStyle) error {
return db.WithTx(stdCtx, func(ctx context.Context) error {
if pr.HasMerged {
return ErrHasMerged
@ -136,7 +136,7 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc
}
}
if _, err := isSignedIfRequired(ctx, pr, doer); err != nil {
if _, err := isSignedIfRequired(ctx, pr, doer, mergeStyle); err != nil {
return err
}
@ -151,7 +151,7 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc
}
// isSignedIfRequired check if merge will be signed if required
func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User) (bool, error) {
func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle) (bool, error) {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil {
return false, err
@ -161,11 +161,22 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
return true, nil
}
if !isMergeSigningRequired(mergeStyle) {
return true, nil
}
sign, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.GetGitRefName())
return sign, err
}
func isMergeSigningRequired(mergeStyle repo_model.MergeStyle) bool {
// Only fast-forward-only is guaranteed not to create a new commit. Rebase
// rewrites commits when the pull request is behind, and it can also amend
// the tip commit when a REBASE_TEMPLATE is configured.
return mergeStyle != repo_model.MergeStyleFastForwardOnly
}
// checkAndUpdateStatus checks if pull request is possible to leaving checking status,
// and set to be either conflict or mergeable.
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) bool {

View file

@ -11,6 +11,7 @@ import (
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/modules/queue"
"forgejo.org/modules/setting"
@ -67,3 +68,53 @@ func TestPullRequest_AddToTaskQueue(t *testing.T) {
prPatchCheckerQueue.ShutdownWait(5 * time.Second)
prPatchCheckerQueue = nil
}
func TestIsMergeSigningRequired(t *testing.T) {
testCases := []struct {
name string
mergeStyle repo_model.MergeStyle
expected bool
}{
{
name: "fast-forward never requires signing",
mergeStyle: repo_model.MergeStyleFastForwardOnly,
expected: false,
},
{
name: "rebase requires signing even when up to date",
mergeStyle: repo_model.MergeStyleRebase,
expected: true,
},
{
name: "rebase-merge requires signing",
mergeStyle: repo_model.MergeStyleRebaseMerge,
expected: true,
},
{
name: "squash commits require signing",
mergeStyle: repo_model.MergeStyleSquash,
expected: true,
},
{
name: "merge commits require signing",
mergeStyle: repo_model.MergeStyleMerge,
expected: true,
},
{
name: "rebase-update style still requires signing",
mergeStyle: repo_model.MergeStyleRebaseUpdate,
expected: true,
},
{
name: "empty merge style requires signing",
mergeStyle: "",
expected: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, isMergeSigningRequired(testCase.mergeStyle))
})
}
}