feat: Add bypass allowlist for branch protection (#36514)

- Introduce a “Bypass Protection Allowlist” on branch rules
(users/teams) alongside admins, with BlockAdminMergeOverride
  still respected.
- Surface the allowlist in API (create/edit options, structs) and
settings UI; merge box now shows the red button +
  message for bypass-capable users.
- Apply bypass logic to merge checks and pre-receive so allowlisted
users can override unmet approvals/status checks/
  protected files when force-merging.
- Add migration for new columns, locale strings, and unit tests (bypass
helper; queue test tweak).

<img width="1069" height="218" alt="image"
src="https://github.com/user-attachments/assets/0b61bc2a-a27f-47f3-a923-613688008e65"
/>


Fixes #36476

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Codex GPT-5.3 <codex@openai.com>
Co-authored-by: GPT-5.2 <noreply@openai.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Nicolas
2026-05-16 16:23:42 +02:00
committed by GitHub
parent 54ff68b0a9
commit eb93981d45
23 changed files with 572 additions and 40 deletions
+33
View File
@@ -356,7 +356,11 @@ func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to s
func TestAPIBranchProtection(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Basic", testAPIBranchProtectionBasic)
t.Run("BypassAllowlistValidation", testAPIBranchProtectionBypassAllowlistValidation)
}
func testAPIBranchProtectionBasic(t *testing.T) {
// Can create branch protection on branch that not exist
testAPICreateBranchProtection(t, "non-existing/branch", 1, http.StatusCreated)
testAPIGetBranchProtection(t, "non-existing/branch", http.StatusOK)
@@ -406,6 +410,35 @@ func TestAPIBranchProtection(t *testing.T) {
testAPIDeleteBranch(t, "no-such-branch", http.StatusNotFound) // non-existing branch, not exist in git or DB
}
func testAPIBranchProtectionBypassAllowlistValidation(t *testing.T) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
t.Run("IgnoreInvalidBypassUsernamesWhenDisabled", func(t *testing.T) {
ruleName := "bypass-disabled-invalid-user"
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.CreateBranchProtectionOption{
RuleName: ruleName,
EnableBypassAllowlist: false,
BypassAllowlistUsernames: []string{"nonexistent-user"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
testAPIDeleteBranchProtection(t, ruleName, http.StatusNoContent)
})
t.Run("IgnoreInvalidBypassTeamsWhenDisabled", func(t *testing.T) {
ruleName := "bypass-disabled-invalid-team"
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/org3/repo3/branch_protections", &api.CreateBranchProtectionOption{
RuleName: ruleName,
EnableBypassAllowlist: false,
BypassAllowlistTeams: []string{"nonexistent-team"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
deleteReq := NewRequestf(t, "DELETE", "/api/v1/repos/org3/repo3/branch_protections/%s", ruleName).
AddTokenAuth(token)
MakeRequest(t, deleteReq, http.StatusNoContent)
})
}
func TestAPICreateBranchWithSyncBranches(t *testing.T) {
defer tests.PrepareTestEnv(t)()
+67
View File
@@ -20,6 +20,7 @@ import (
"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/perm"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
@@ -29,6 +30,7 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
@@ -1092,6 +1094,71 @@ func TestPullNonMergeForAdminWithBranchProtection(t *testing.T) {
})
}
func TestPullForceMergeForBypassAllowlistUser(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
ownerSession := loginUser(t, "user2")
ownerCtx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
bypassUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user4"})
doAPIAddCollaborator(ownerCtx, bypassUser.Name, perm.AccessModeWrite)(t)
bypassSession := loginUser(t, bypassUser.Name)
forkedName := "repo1-bypass-allowlist"
testRepoFork(t, bypassSession, "user2", "repo1", bypassUser.Name, forkedName, "")
defer testDeleteRepository(t, bypassSession, bypassUser.Name, forkedName)
testEditFile(t, bypassSession, bypassUser.Name, forkedName, "master", "README.md", "Hello, World (Bypass Allowlist)\n")
resp := testPullCreate(t, bypassSession, bypassUser.Name, forkedName, false, "master", "master", "Bypass allowlist merge test pull")
pullURL := test.RedirectURL(resp)
elem := strings.Split(pullURL, "/")
assert.Equal(t, "pulls", elem[3])
prIndex, err := strconv.ParseInt(elem[4], 10, 64)
assert.NoError(t, err)
pbCreateReq := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
"rule_name": "master",
"enable_push": "all",
"enable_status_check": "true",
"status_check_contexts": "gitea/actions",
"block_admin_merge_override": "true",
"enable_bypass_allowlist": "on",
"bypass_allowlist_users": strconv.FormatInt(bypassUser.ID, 10),
})
ownerSession.MakeRequest(t, pbCreateReq, http.StatusSeeOther)
defer testAPIDeleteBranchProtection(t, "master", http.StatusNoContent)
token := getTokenForLoggedInUser(t, bypassSession, auth_model.AccessTokenScopeWriteRepository)
resp = bypassSession.MakeRequest(t, NewRequest(t, "GET", pullURL), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t, htmlDoc.doc.Find(".merge-section").Text(), "You are allowed to bypass branch protection rules for this merge.")
mergeFormProps, exists := htmlDoc.doc.Find("#pull-request-merge-form").Attr("data-merge-form-props")
require.True(t, exists)
var mergeForm map[string]any
require.NoError(t, json.Unmarshal([]byte(mergeFormProps), &mergeForm))
assert.Equal(t, true, mergeForm["canMergeNow"])
assert.Equal(t, false, mergeForm["allOverridableChecksOk"])
mergeReq := func(forceMerge bool) *RequestWrapper {
return NewRequestWithValues(t, "POST", fmt.Sprintf("/api/v1/repos/user2/repo1/pulls/%d/merge", prIndex), map[string]string{
"head_commit_id": "",
"merge_when_checks_succeed": "false",
"force_merge": strconv.FormatBool(forceMerge),
"do": "rebase",
}).AddTokenAuth(token)
}
bypassSession.MakeRequest(t, mergeReq(false), http.StatusMethodNotAllowed)
bypassSession.MakeRequest(t, mergeReq(true), http.StatusOK)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
pr, err := issues_model.GetPullRequestByIndex(t.Context(), baseRepo.ID, prIndex)
assert.NoError(t, err)
assert.True(t, pr.HasMerged)
})
}
func TestPullSquashMergeEmpty(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user1") // FIXME: don't use admin user for testing