Allow fast-forward-only merge when signed commits are required (#37335)

Fast-forward-only creates no Gitea commit, so skip the "can Gitea sign"
precheck for it. Pre-check head-commit verification for styles that
preserve user commits on the target (merge, fast-forward-only) so a PR
with unsigned commits surfaces a localized error instead of a 500 at the
pre-receive hook. The dropdown still shows every configured style; the
avatar and signing warning toggle per selection via
data-pull-merge-style.

Fixes #12272 

**Note**: Admin force-merge does not bypass the new head-commits check.
This matches the existing `isSignedIfRequired` behavior.

Signed-off-by: Nikita Vakula <programmistov.programmist@gmail.com>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Nikita Vakula
2026-04-24 02:04:32 +02:00
committed by GitHub
parent 899ede1d55
commit 3b2fd9791c
8 changed files with 205 additions and 38 deletions

View File

@@ -338,26 +338,41 @@ Loop:
return false, nil, nil, &ErrWontSign{headSigned}
}
case commitsSigned:
verification := ParseCommitWithSignature(ctx, headCommit)
if !verification.Verified {
verified, err := AllHeadCommitsVerified(ctx, pr, gitRepo)
if err != nil {
return false, nil, nil, err
}
if !verified {
return false, nil, nil, &ErrWontSign{commitsSigned}
}
// need to work out merge-base
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
if err != nil {
return false, nil, nil, err
}
commitList, err := headCommit.CommitsBeforeUntil(mergeBaseCommit)
if err != nil {
return false, nil, nil, err
}
for _, commit := range commitList {
verification := ParseCommitWithSignature(ctx, commit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{commitsSigned}
}
}
}
}
return true, signingKey, signer, nil
}
// AllHeadCommitsVerified checks that every new commit in the PR head has a
// verified signature.
func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) (bool, error) {
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
if err != nil {
return false, err
}
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
if err != nil {
return false, err
}
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
if err != nil {
return false, err
}
commitList, err := headCommit.CommitsBeforeUntil(mergeBaseCommit)
if err != nil {
return false, err
}
for _, commit := range commitList {
if !ParseCommitWithSignature(ctx, commit).Verified {
return false, nil
}
}
return true, nil
}

View File

@@ -251,7 +251,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, scheduledPRM.MergeStyle, false); err != nil {
if errors.Is(err, pull_service.ErrNotReadyToMerge) {
log.Info("%-v was scheduled to automerge by an unauthorized user", pr)
return

View File

@@ -38,14 +38,15 @@ import (
var prPatchCheckerQueue *queue.WorkerPoolQueue[string]
var (
ErrIsClosed = errors.New("pull is closed")
ErrNoPermissionToMerge = errors.New("no permission to merge")
ErrNotReadyToMerge = errors.New("not ready to merge")
ErrHasMerged = errors.New("has already been merged")
ErrIsWorkInProgress = errors.New("work in progress PRs cannot be merged")
ErrIsChecking = errors.New("cannot merge while conflict checking is in progress")
ErrNotMergeableState = errors.New("not in mergeable state")
ErrDependenciesLeft = errors.New("is blocked by an open dependency")
ErrIsClosed = errors.New("pull is closed")
ErrNoPermissionToMerge = errors.New("no permission to merge")
ErrNotReadyToMerge = errors.New("not ready to merge")
ErrHasMerged = errors.New("has already been merged")
ErrIsWorkInProgress = errors.New("work in progress PRs cannot be merged")
ErrIsChecking = errors.New("cannot merge while conflict checking is in progress")
ErrNotMergeableState = errors.New("not in mergeable state")
ErrDependenciesLeft = errors.New("is blocked by an open dependency")
ErrHeadCommitsNotAllVerified = errors.New("the branch requires signed commits but not all head commits are verified")
)
func markPullRequestStatusAsChecking(ctx context.Context, pr *issues_model.PullRequest) bool {
@@ -132,7 +133,13 @@ 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, adminForceMerge bool) error {
// mergeStyle tailors the "require signed commits" prechecks:
// - fast-forward-only: no Gitea commit is produced, so Gitea's merge-signing check is skipped;
// only the user's head commits are verified.
// - merge: both the head commits must be verified and Gitea must sign the merge commit.
// - rebase, rebase-merge, squash: Gitea rewrites the commits and signs each, so only Gitea's
// signing ability is checked.
func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, mergeStyle repo_model.MergeStyle, adminForceMerge bool) error {
return db.WithTx(stdCtx, func(ctx context.Context) error {
if pr.HasMerged {
return ErrHasMerged
@@ -207,7 +214,7 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc
}
}
if _, err := isSignedIfRequired(ctx, pr, doer); err != nil {
if err := checkSigningRequirements(ctx, pr, doer, mergeStyle); err != nil {
return err
}
@@ -221,26 +228,45 @@ 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) {
// checkSigningRequirements enforces the target branch's RequireSignedCommits rule
// against the selected merge style:
// - fast-forward-only and merge keep the user's commits on the base branch, so
// those commits must all be verified, or the pre-receive hook will reject the
// push with a generic error.
// - fast-forward-only creates no Gitea commit, so Gitea's signing key is not used.
// - merge, rebase, rebase-merge and squash produce a Gitea-signed commit, so
// Gitea must be configured to sign it.
func checkSigningRequirements(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle) error {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil {
return false, err
return err
}
if pb == nil || !pb.RequireSignedCommits {
return true, nil
return nil
}
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
if err != nil {
return false, err
return err
}
defer closer.Close()
sign, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo)
if mergeStyle == repo_model.MergeStyleFastForwardOnly || mergeStyle == repo_model.MergeStyleMerge {
verified, err := asymkey_service.AllHeadCommitsVerified(ctx, pr, gitRepo)
if err != nil {
return err
}
if !verified {
return ErrHeadCommitsNotAllVerified
}
}
return sign, err
if mergeStyle != repo_model.MergeStyleFastForwardOnly {
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo); err != nil {
return err
}
}
return nil
}
// markPullRequestAsMergeable checks if pull request is possible to leaving checking status,

View File

@@ -9,6 +9,7 @@ import (
"time"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
@@ -73,6 +74,39 @@ func TestPullRequest_AddToTaskQueue(t *testing.T) {
prPatchCheckerQueue = nil
}
func TestCheckSigningRequirementsHeadCommits(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
require.NoError(t, pr.LoadBaseRepo(ctx))
require.NoError(t, pr.LoadHeadRepo(ctx))
check := func() error {
return checkSigningRequirements(ctx, pr, nil, repo_model.MergeStyleFastForwardOnly)
}
// No protected branch rule on the base branch: the check must pass.
require.NoError(t, check())
// Protected branch without RequireSignedCommits: the check must still pass.
require.NoError(t, git_model.UpdateProtectBranch(ctx, pr.BaseRepo, &git_model.ProtectedBranch{
RepoID: pr.BaseRepoID,
RuleName: pr.BaseBranch,
RequireSignedCommits: false,
}, git_model.WhitelistOptions{}))
require.NoError(t, check())
// With RequireSignedCommits enabled: the test fixture commits have no signatures,
// so the check must report ErrHeadCommitsNotAllVerified.
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
require.NoError(t, err)
require.NotNil(t, pb)
pb.RequireSignedCommits = true
require.NoError(t, git_model.UpdateProtectBranch(ctx, pr.BaseRepo, pb, git_model.WhitelistOptions{}))
require.ErrorIs(t, check(), ErrHeadCommitsNotAllVerified)
}
func TestMarkPullRequestAsMergeable(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())