Compare commits

...

14 Commits

Author SHA1 Message Date
Jonathan Miller dd6fc4b69c fix(actions): retry workflow insertion on database deadlock
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Failing after 21s
When multiple workflows are triggered by a single event (e.g. a
pull_request with several matching workflow files), each InsertRun
transaction acquires an X-lock on the repository row via
UpdateRepoRunsNumbers and an index lock on action_run. Two concurrent
transactions can deadlock when each holds one lock and waits for the
other. InnoDB kills the lighter transaction, but handleWorkflows only
logged the error and silently dropped the workflow run — making it
appear as though pull_request events were never fired.

This was the root cause of API-created PRs appearing to not trigger
Actions workflows: the notification pipeline was correct, but the DB
insert was lost to an unretried deadlock.

The fix wraps PrepareRunAndInsert in a retry loop (up to 3 attempts
with exponential backoff) that detects deadlock errors across MySQL,
PostgreSQL, and SQLite. On deadlock, the rolled-back run fields are
reset before the next attempt.

Also adds db.IsErrDeadlock() for cross-engine deadlock detection and
unit tests for the same.

Closes #220

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:16:30 -05:00
jmiller c633024a9c Merge pull request 'rc(v05.05.00): org 2FA, wiki slugs, http render fix' (#219) from rc/05.05.00 into main 2026-05-26 18:50:40 +00:00
jmiller 8ffdbff72a Merge pull request 'fix: preserve + and . in wiki slugs' (#218) from fix/wiki-slug-polish into dev
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 21s
2026-05-26 18:48:06 +00:00
Jonathan Miller d609b8db8c fix: preserve + and . in wiki slugs, clean stray plus signs
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 1s
Allow C++, .NET, version numbers (2.0.1) in wiki filenames.
Clean up isolated plus signs that appear between hyphens.

Examples:
- C++ vs C# -> C++-vs-C.md
- .NET Guide -> .NET-Guide.md
- version 2.0.1 -> version-2.0.1-release.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:47:59 -05:00
jmiller bf35e5510d Merge pull request 'fix: unused import in require2fa.go' (#216) from fix/require2fa-import into dev 2026-05-26 18:39:22 +00:00
Jonathan Miller 0cc7297f23 fix: remove unused net/http import in require2fa.go
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:39:15 -05:00
jmiller 9dc85cfc2d Merge pull request 'feat: smart wiki filenames' (#215) from fix/wiki-smart-filenames into dev 2026-05-26 18:28:14 +00:00
jmiller 6bc0cb5bc8 Merge pull request 'feat: org-level 2FA requirement (#208)' (#214) from feat/208-org-2fa-requirement into dev 2026-05-26 18:28:05 +00:00
Jonathan Miller 1fb97eeeeb feat: smart wiki filenames — sanitize special characters to hyphens
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
New wiki page titles are now sanitized before creating the git file:
- Spaces and special characters replaced with hyphens
- Consecutive hyphens collapsed to single hyphen
- Leading/trailing hyphens trimmed

Examples:
- "My Page Name" -> "My-Page-Name"
- "API & Docs (v2)" -> "API-Docs-v2"
- "100% Complete!!" -> "100-Complete"

Only affects NEW pages. Existing wiki pages with legacy filenames
(spaces, URL encoding) continue to work — the read path is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:22:21 -05:00
Jonathan Miller 1032ae4268 feat: organization-level 2FA requirement for members (#208)
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Adds a Require2FA toggle to organization settings. When enabled,
org members without 2FA are redirected to the security settings
page with a warning flash message.

Changes:
- New Require2FA field on User model (migration v333)
- Org settings UI checkbox with shield-lock icon
- Check2FARequirement middleware on member-required org routes
- UpdateOptions extended with Require2FA field

Closes #208

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:11:15 -05:00
jmiller af1c6178ef Merge pull request 'fix: http content file render (#207)' (#212) from fix/207-http-content-render into dev 2026-05-26 17:55:43 +00:00
jmiller 1e1441f8bd Merge pull request 'rc(v05.04.00): login notifications, help footer, login logo, checksum fix' (#210) from rc/05.04.00 into main 2026-05-26 17:47:13 +00:00
jmiller 3c55a3baca Merge pull request 'rc: footer help + login logo' (#206) from rc/05.03.08 into main 2026-05-26 16:12:57 +00:00
jmiller b7f9743ade Merge pull request 'rc: fix dashboard icon' (#203) from rc/05.03.06 into main 2026-05-26 04:39:15 +00:00
12 changed files with 200 additions and 3 deletions
+25
View File
@@ -5,6 +5,7 @@ package db
import (
"fmt"
"strings"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
)
@@ -72,3 +73,27 @@ func (err ErrNotExist) Error() string {
func (err ErrNotExist) Unwrap() error {
return util.ErrNotExist
}
// IsErrDeadlock checks whether err is a database deadlock.
// MySQL returns error 1213 (ER_LOCK_DEADLOCK / SQLSTATE 40001).
// PostgreSQL returns SQLSTATE 40P01 with "deadlock detected".
// SQLite returns SQLITE_BUSY (error 5) with "database is locked".
func IsErrDeadlock(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// MySQL / MariaDB: "Error 1213 (40001): Deadlock found when trying to get lock"
if strings.Contains(msg, "Error 1213") || strings.Contains(msg, "40001") {
return true
}
// PostgreSQL: "deadlock detected"
if strings.Contains(msg, "deadlock detected") {
return true
}
// SQLite: "database is locked"
if strings.Contains(msg, "database is locked") {
return true
}
return false
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package db
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsErrDeadlock(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "unrelated", err: errors.New("connection refused"), want: false},
{name: "mysql 1213", err: errors.New("Error 1213 (40001): Deadlock found when trying to get lock; try restarting transaction"), want: true},
{name: "mysql sqlstate", err: errors.New("SQLSTATE 40001: serialization failure"), want: true},
{name: "postgres", err: errors.New("pq: deadlock detected"), want: true},
{name: "sqlite", err: errors.New("database is locked"), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsErrDeadlock(tt.err))
})
}
}
+1
View File
@@ -410,6 +410,7 @@ func prepareMigrationTasks() []*migration {
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable),
newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser),
}
return preparedMigrations
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"xorm.io/xorm"
)
func AddRequire2FAToUser(x *xorm.Engine) error {
type User struct {
Require2FA bool `xorm:"NOT NULL DEFAULT false"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(User))
return err
}
+3
View File
@@ -117,6 +117,9 @@ type User struct {
// Maximum repository creation limit, -1 means use global default
MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"`
// Require2FA when true (and user is an org), all org members must have 2FA enabled
Require2FA bool `xorm:"NOT NULL DEFAULT false"`
// IsActive true: primary email is activated, user can access Web UI and Git SSH.
// false: an inactive user can only log in Web UI for account operations (ex: activate the account by email), no other access.
IsActive bool `xorm:"INDEX"`
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package org
import (
auth_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// Check2FARequirement checks if the current org requires 2FA and if the user has it enabled.
// If the user doesn't have 2FA and the org requires it, redirect to 2FA setup page.
func Check2FARequirement(ctx *context.Context) {
if ctx.Org == nil || ctx.Org.Organization == nil || ctx.Doer == nil {
return
}
if !ctx.Org.Organization.Require2FA {
return
}
// Check if user has 2FA enabled
has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
if has {
return
}
// User doesn't have 2FA — show warning and redirect to settings
ctx.Flash.Warning("This organization requires two-factor authentication. Please enable 2FA to continue.")
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
}
+2
View File
@@ -80,12 +80,14 @@ func SettingsPost(ctx *context.Context) {
return
}
require2FA := ctx.FormBool("require_2fa")
opts := &user_service.UpdateOptions{
FullName: optional.FromPtr(form.FullName),
Description: optional.FromPtr(form.Description),
Website: optional.FromPtr(form.Website),
Location: optional.FromPtr(form.Location),
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
Require2FA: optional.Some(require2FA),
}
if ctx.Doer.IsAdmin {
opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation)
+1 -1
View File
@@ -960,7 +960,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones)
m.Post("/members/action/{action}", org.MembersAction)
m.Get("/teams", org.Teams)
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}), org.Check2FARequirement)
m.Group("/{org}", func() {
m.Get("/teams/{team}", org.TeamMembers)
+50 -1
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"slices"
"strings"
"time"
actions_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/actions"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
@@ -344,7 +345,7 @@ func handleWorkflows(
run.NeedApproval = need
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
if err := prepareRunAndInsertWithRetry(ctx, dwf.Content, run); err != nil {
log.Error("PrepareRunAndInsert: %v", err)
continue
}
@@ -352,6 +353,54 @@ func handleWorkflows(
return nil
}
// prepareRunAndInsertWithRetry wraps PrepareRunAndInsert with retries on
// database deadlocks. When multiple workflow runs are inserted for the same
// event (e.g. several workflows triggered by a single pull_request), each
// InsertRun transaction acquires an X-lock on the repository row (via
// UpdateRepoRunsNumbers) and an index lock on action_run. Two concurrent
// transactions can deadlock when each holds one lock and waits for the other.
// InnoDB resolves this by killing the lighter transaction, but handleWorkflows
// only logged the error and moved on — silently dropping the workflow run.
// Retrying the insert is safe because the rolled-back transaction left no
// partial state.
func prepareRunAndInsertWithRetry(ctx context.Context, content []byte, run *actions_model.ActionRun) error {
const maxRetries = 3
backoff := 50 * time.Millisecond
// Save original values that InsertRun mutates inside its transaction.
// On deadlock rollback these become stale and must be reset before retry.
origTitle := run.Title
var err error
for attempt := range maxRetries {
if err = PrepareRunAndInsert(ctx, content, run, nil); err == nil {
return nil
}
if !db.IsErrDeadlock(err) {
return err
}
log.Warn("PrepareRunAndInsert deadlock (attempt %d/%d) for workflow %s in repo %d, retrying: %v",
attempt+1, maxRetries, run.WorkflowID, run.RepoID, err)
// Reset fields that InsertRun sets inside the (now rolled-back) transaction
// so the next attempt starts clean.
run.ID = 0
run.Index = 0
run.Status = actions_model.StatusWaiting
run.Title = origTitle
run.ConcurrencyGroup = ""
run.ConcurrencyCancel = false
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
}
return fmt.Errorf("deadlock persisted after %d retries: %w", maxRetries, err)
}
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
return newNotifyInput(issue.Repo, issue.Poster, event)
}
+6
View File
@@ -56,6 +56,7 @@ type UpdateOptions struct {
EmailNotificationsPreference optional.Option[string]
SetLastLogin bool
RepoAdminChangeTeamAccess optional.Option[bool]
Require2FA optional.Option[bool]
}
func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
@@ -169,6 +170,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "repo_admin_change_team_access")
}
if opts.Require2FA.Has() {
u.Require2FA = opts.Require2FA.Value()
cols = append(cols, "require_2fa")
}
if opts.EmailNotificationsPreference.Has() {
u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value()
+18 -1
View File
@@ -6,6 +6,7 @@ package wiki
import (
"net/url"
"path"
"regexp"
"strings"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
@@ -148,10 +149,26 @@ func WebPathFromRequest(s string) WebPath {
return WebPath(s)
}
var multiHyphenRe = regexp.MustCompile(`-{2,}`)
var nonSlugRe = regexp.MustCompile(`[^a-zA-Z0-9+.\-]`)
// sanitizeWikiTitle converts a user-provided title into a clean, URL-friendly slug.
// Spaces and special characters become hyphens, consecutive hyphens collapse to one.
// Preserves: letters, digits, hyphens, plus signs (+), and dots (.)
func sanitizeWikiTitle(title string) string {
title = strings.TrimSpace(title)
title = strings.ReplaceAll(title, " ", "-")
title = nonSlugRe.ReplaceAllString(title, "-")
title = multiHyphenRe.ReplaceAllString(title, "-")
title = strings.NewReplacer("-+-", "-", "+-", "-", "-+", "-").Replace(title) // clean stray plus signs
title = strings.Trim(title, "-+.")
return title
}
func UserTitleToWebPath(base, title string) WebPath {
// TODO: no support for subdirectory, because the old wiki code's behavior is always using %2F, instead of subdirectory.
// So we do not add the support for writing slashes in title at the moment.
title = strings.TrimSpace(title)
title = sanitizeWikiTitle(title)
title = util.PathJoinRelX(base, escapeSegToWeb(title, false))
if title == "" || title == "." {
title = "unnamed"
+10
View File
@@ -48,6 +48,16 @@
</div>
{{end}}
<div class="divider"></div>
<div class="inline field">
<div class="ui checkbox">
<input type="checkbox" name="require_2fa" {{if .Org.Require2FA}}checked{{end}}>
<label>{{svg "octicon-shield-lock" 16}} Require two-factor authentication for all members</label>
</div>
<p class="help">When enabled, organization members without 2FA configured will be prompted to set it up before accessing organization resources.</p>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "org.settings.update_settings"}}</button>
</div>