fix(actions): default-branch push discards a due schedule occurrence (#40073)

Default-branch pushes and Actions unit saves recreated the repository's
schedules with `Next` recomputed from that moment, so a push between a
cron's due minute and the scheduler's pass skipped the occurrence, and
`@every` timers restarted.

Re-registration now updates schedule rows in place and keeps the spec
rows of unchanged crons, so their cursor survives and an in-flight
scheduler pass still finds its schedule. Re-detection also clears
schedules when no scheduled workflow remains.

Fixes https://github.com/go-gitea/gitea/issues/40062

Written with AI assistance.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 5.5) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-31 17:31:59 +01:00
1 parent 7df657116c
commit bbb8ef8244
6 files changed
+111 -27

No files matched your search

+47 -9
View File
@@ -41,26 +41,57 @@ func init() {
db.RegisterModel(new(ActionSchedule))
}
// CreateScheduleTaskBySchedules creates new schedule task.
func CreateScheduleTaskBySchedules(ctx context.Context, rows []*ActionSchedule) error {
// Return early if there are no rows to insert
if len(rows) == 0 {
return nil
}
type scheduleSpecKey struct {
scheduleID int64
spec string
}
// ReplaceScheduleTasksByRepo updates schedules in place and keeps the specs of unchanged crons, so re-registering cannot skip a due occurrence.
func ReplaceScheduleTasksByRepo(ctx context.Context, repoID int64, rows []*ActionSchedule) error {
return db.WithTx(ctx, func(ctx context.Context) error {
var oldSchedules []*ActionSchedule
if err := db.GetEngine(ctx).Cols("id", "workflow_id").Where("repo_id = ?", repoID).Find(&oldSchedules); err != nil {
return err
}
var oldSpecs []*ActionScheduleSpec
if err := db.GetEngine(ctx).Cols("id", "schedule_id", "spec").Where("repo_id = ?", repoID).Find(&oldSpecs); err != nil {
return err
}
scheduleIDs := make(map[string]int64, len(oldSchedules))
for _, schedule := range oldSchedules {
scheduleIDs[schedule.WorkflowID] = schedule.ID
}
specIDs := make(map[scheduleSpecKey]int64, len(oldSpecs))
for _, spec := range oldSpecs {
specIDs[scheduleSpecKey{spec.ScheduleID, spec.Spec}] = spec.ID
}
var keptScheduleIDs, keptSpecIDs []int64
// Loop through each schedule row
for _, row := range rows {
row.Title = util.EllipsisDisplayString(row.Title, 255)
// Create new schedule row
if err := db.Insert(ctx, row); err != nil {
if id, ok := scheduleIDs[row.WorkflowID]; ok {
row.ID = id
if _, err := db.GetEngine(ctx).ID(id).AllCols().Update(row); err != nil {
return err
}
} else if err := db.Insert(ctx, row); err != nil {
return err
}
keptScheduleIDs = append(keptScheduleIDs, row.ID)
// Loop through each schedule spec and create a new spec row
now := time.Now()
for _, spec := range row.Specs {
key := scheduleSpecKey{row.ID, spec}
if id, ok := specIDs[key]; ok {
delete(specIDs, key)
keptSpecIDs = append(keptSpecIDs, id)
continue
}
specRow := &ActionScheduleSpec{
RepoID: row.RepoID,
ScheduleID: row.ID,
@@ -81,9 +112,16 @@ func CreateScheduleTaskBySchedules(ctx context.Context, rows []*ActionSchedule)
if err = db.Insert(ctx, specRow); err != nil {
return err
}
keptSpecIDs = append(keptSpecIDs, specRow.ID)
}
}
return nil
// an empty NotIn matches every row
if _, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).NotIn("id", keptScheduleIDs).Delete(&ActionSchedule{}); err != nil {
return err
}
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).NotIn("id", keptSpecIDs).Delete(&ActionScheduleSpec{})
return err
})
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"time"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReplaceScheduleTasksByRepo(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
const repoID = 4711
register := func(commitSHA, spec string) {
require.NoError(t, ReplaceScheduleTasksByRepo(t.Context(), repoID, []*ActionSchedule{
{RepoID: repoID, WorkflowID: "nightly.yml", CommitSHA: commitSHA, Specs: []string{spec}},
}))
}
register("c1", "17 */6 * * *")
spec := unittest.AssertExistsAndLoadBean(t, &ActionScheduleSpec{RepoID: repoID})
spec.Next = timeutil.TimeStamp(time.Now().Add(-time.Minute).Unix())
require.NoError(t, UpdateScheduleSpec(t.Context(), spec, "next"))
register("c2", "17 */6 * * *")
unittest.AssertExistsAndLoadBean(t, &ActionSchedule{ID: spec.ScheduleID, CommitSHA: "c2"})
unittest.AssertExistsAndLoadBean(t, &ActionScheduleSpec{ID: spec.ID, ScheduleID: spec.ScheduleID, Next: spec.Next})
register("c3", "23 */6 * * *")
unittest.AssertNotExistsBean(t, &ActionScheduleSpec{ID: spec.ID})
assert.Greater(t, unittest.AssertExistsAndLoadBean(t, &ActionScheduleSpec{RepoID: repoID}).Next, spec.Next)
require.NoError(t, ReplaceScheduleTasksByRepo(t.Context(), repoID, nil))
unittest.AssertNotExistsBean(t, &ActionSchedule{RepoID: repoID})
unittest.AssertNotExistsBean(t, &ActionScheduleSpec{RepoID: repoID})
}