Skip to content

Commit 21a73ae

Browse files
authored
Use UTC as default timezone when schedule Actions cron tasks (#31742)
Fix #31657. According to the [doc](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#onschedule) of GitHub Actions, The timezone for cron should be UTC, not the local timezone. And Gitea Actions doesn't have any reasons to change this, so I think it's a bug. However, Gitea Actions has extended the syntax, as it supports descriptors like `@weekly` and `@every 5m`, and supports specifying the timezone like `TZ=UTC 0 10 * * *`. So we can make it use UTC only when the timezone is not specified, to be compatible with GitHub Actions, and also respect the user's specified. It does break the feature because the times to run tasks would be changed, and it may confuse users. So I don't think we should backport this. ## ⚠️ BREAKING ⚠️ If the server's local time zone is not UTC, a scheduled task would run at a different time after upgrading Gitea to this version.
1 parent 333c9ed commit 21a73ae

File tree

3 files changed

+104
-12
lines changed

3 files changed

+104
-12
lines changed

models/actions/schedule.go

+9-11
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import (
1313
user_model "code.gitea.io/gitea/models/user"
1414
"code.gitea.io/gitea/modules/timeutil"
1515
webhook_module "code.gitea.io/gitea/modules/webhook"
16-
17-
"github.com/robfig/cron/v3"
1816
)
1917

2018
// ActionSchedule represents a schedule of a workflow file
@@ -53,8 +51,6 @@ func GetReposMapByIDs(ctx context.Context, ids []int64) (map[int64]*repo_model.R
5351
return repos, db.GetEngine(ctx).In("id", ids).Find(&repos)
5452
}
5553

56-
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
57-
5854
// CreateScheduleTask creates new schedule task.
5955
func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error {
6056
// Return early if there are no rows to insert
@@ -80,19 +76,21 @@ func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error {
8076
now := time.Now()
8177

8278
for _, spec := range row.Specs {
79+
specRow := &ActionScheduleSpec{
80+
RepoID: row.RepoID,
81+
ScheduleID: row.ID,
82+
Spec: spec,
83+
}
8384
// Parse the spec and check for errors
84-
schedule, err := cronParser.Parse(spec)
85+
schedule, err := specRow.Parse()
8586
if err != nil {
8687
continue // skip to the next spec if there's an error
8788
}
8889

90+
specRow.Next = timeutil.TimeStamp(schedule.Next(now).Unix())
91+
8992
// Insert the new schedule spec row
90-
if err = db.Insert(ctx, &ActionScheduleSpec{
91-
RepoID: row.RepoID,
92-
ScheduleID: row.ID,
93-
Spec: spec,
94-
Next: timeutil.TimeStamp(schedule.Next(now).Unix()),
95-
}); err != nil {
93+
if err = db.Insert(ctx, specRow); err != nil {
9694
return err
9795
}
9896
}

models/actions/schedule_spec.go

+24-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package actions
55

66
import (
77
"context"
8+
"strings"
9+
"time"
810

911
"code.gitea.io/gitea/models/db"
1012
repo_model "code.gitea.io/gitea/models/repo"
@@ -32,8 +34,29 @@ type ActionScheduleSpec struct {
3234
Updated timeutil.TimeStamp `xorm:"updated"`
3335
}
3436

37+
// Parse parses the spec and returns a cron.Schedule
38+
// Unlike the default cron parser, Parse uses UTC timezone as the default if none is specified.
3539
func (s *ActionScheduleSpec) Parse() (cron.Schedule, error) {
36-
return cronParser.Parse(s.Spec)
40+
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
41+
schedule, err := parser.Parse(s.Spec)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
// If the spec has specified a timezone, use it
47+
if strings.HasPrefix(s.Spec, "TZ=") || strings.HasPrefix(s.Spec, "CRON_TZ=") {
48+
return schedule, nil
49+
}
50+
51+
specSchedule, ok := schedule.(*cron.SpecSchedule)
52+
// If it's not a spec schedule, like "@every 5m", timezone is not relevant
53+
if !ok {
54+
return schedule, nil
55+
}
56+
57+
// Set the timezone to UTC
58+
specSchedule.Location = time.UTC
59+
return specSchedule, nil
3760
}
3861

3962
func init() {

models/actions/schedule_spec_test.go

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package actions
5+
6+
import (
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestActionScheduleSpec_Parse(t *testing.T) {
15+
// Mock the local timezone is not UTC
16+
local := time.Local
17+
tz, err := time.LoadLocation("Asia/Shanghai")
18+
require.NoError(t, err)
19+
defer func() {
20+
time.Local = local
21+
}()
22+
time.Local = tz
23+
24+
now, err := time.Parse(time.RFC3339, "2024-07-31T15:47:55+08:00")
25+
require.NoError(t, err)
26+
27+
tests := []struct {
28+
name string
29+
spec string
30+
want string
31+
wantErr assert.ErrorAssertionFunc
32+
}{
33+
{
34+
name: "regular",
35+
spec: "0 10 * * *",
36+
want: "2024-07-31T10:00:00Z",
37+
wantErr: assert.NoError,
38+
},
39+
{
40+
name: "invalid",
41+
spec: "0 10 * *",
42+
want: "",
43+
wantErr: assert.Error,
44+
},
45+
{
46+
name: "with timezone",
47+
spec: "TZ=America/New_York 0 10 * * *",
48+
want: "2024-07-31T14:00:00Z",
49+
wantErr: assert.NoError,
50+
},
51+
{
52+
name: "timezone irrelevant",
53+
spec: "@every 5m",
54+
want: "2024-07-31T07:52:55Z",
55+
wantErr: assert.NoError,
56+
},
57+
}
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
s := &ActionScheduleSpec{
61+
Spec: tt.spec,
62+
}
63+
got, err := s.Parse()
64+
tt.wantErr(t, err)
65+
66+
if err == nil {
67+
assert.Equal(t, tt.want, got.Next(now).UTC().Format(time.RFC3339))
68+
}
69+
})
70+
}
71+
}

0 commit comments

Comments
 (0)