feat: support timezone in scheduled workflows (#11851)

GitHub recently added the ability to [specify a time zone for scheduled workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onschedule), thereby making it possible to run scheduled workflows at a certain local time, no matter whether daylight saving time (DST) is currently active or not. Example copied from GitHub's documentation:

```yaml
on:
  schedule:
    - cron: '30 5 * * 1-5'
      timezone: "America/New_York"
```

The workflow would run at 05:30 each morning in the America/New_York timezone every Monday through Friday. `timezone` accepts IANA time zone names. If `timezone` is absent, `Etc/UTC` is used. GitHub runs workflows that were scheduled during DST jumps forward, for example, between 2 o'clock and 3 o'clock, directly after the clock jumped forward. In this case, that would be 3 o'clock.

Forgejo already supports time zones by prepending cron schedules with `TZ=<zone-id>` or `CRON_TZ=<zone-id>`:

```yaml
on:
  schedule:
    - cron: 'CRON_TZ=America/New_York 30 5 * * 1-5'
```

However, that capability is not documented. Workflows that are scheduled to run during DST changes are skipped when the clock jumps forward and run twice when it jumps backward.

This two-part PR adds support for `timezone` to improve compatibility with GitHub. `TZ` and `CRON_TZ` continue working. When both `timezone` and `TZ` or `CRON_TZ` are present, `timezone` takes precedence. When neither `timezone` nor `TZ` nor `CRON_TZ` are present, `Etc/UTC` is used as before. Because `TZ` and `CRON_TZ` were already supported by Forgejo before GitHub introduced `timezone`, `timezone` behaves during DST changes as previous versions of Forgejo, thereby deviating from GitHub. That means that workflows that are scheduled to run during DST changes are skipped when the clock jumps forward. And they run twice when it jumps backwards. However, it is generally recommended not to schedule workflows during the time of day when DST changes occur.

This part of the PR integrates the [workflow validation and parsing of the `timezone` field](https://code.forgejo.org/forgejo/runner/pulls/1454) supplied by Forgejo Runner.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(can be removed for Go changes)

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
    - https://codeberg.org/forgejo/docs/pulls/1853
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/11851): <!--number 11851 --><!--line 0 --><!--description c3VwcG9ydCBgdGltZXpvbmVgIGluIHNjaGVkdWxlZCB3b3JrZmxvd3M=-->support `timezone` in scheduled workflows<!--description-->
<!--end release-notes-assistant-->

Co-authored-by: Renovate Bot <bot@kriese.eu>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11851
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
Co-committed-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
This commit is contained in:
Andreas Ahlenstorf 2026-04-04 18:23:06 +02:00 committed by Mathieu Fenniak
parent 2d2029c598
commit df86b495dc
11 changed files with 323 additions and 58 deletions

View file

@ -5,7 +5,6 @@ package actions
import (
"context"
"time"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
@ -21,7 +20,7 @@ import (
type ActionSchedule struct {
ID int64
Title string
Specs []string
Specs []*ActionScheduleSpec `xorm:"-"`
RepoID int64 `xorm:"index"`
Repo *repo_model.Repository `xorm:"-"`
OwnerID int64 `xorm:"index"`
@ -73,25 +72,12 @@ func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error {
return err
}
// Loop through each schedule spec and create a new spec row
now := time.Now()
for _, spec := range row.Specs {
specRow := &ActionScheduleSpec{
RepoID: row.RepoID,
ScheduleID: row.ID,
Spec: spec,
}
// Parse the spec and check for errors
schedule, err := specRow.Parse()
if err != nil {
continue // skip to the next spec if there's an error
}
specRow.Next = timeutil.TimeStamp(schedule.Next(now).Unix())
spec.ScheduleID = row.ID
spec.RepoID = row.RepoID
// Insert the new schedule spec row
if err = db.Insert(ctx, specRow); err != nil {
if err = db.Insert(ctx, spec); err != nil {
return err
}
}

View file

@ -10,6 +10,7 @@ import (
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"github.com/robfig/cron/v3"
@ -27,13 +28,28 @@ type ActionScheduleSpec struct {
// started or this entry's schedule is unsatisfiable
Next timeutil.TimeStamp `xorm:"index"`
// Prev is the last time this job was run, or the zero time if never.
Prev timeutil.TimeStamp
Spec string
Prev timeutil.TimeStamp
Spec string
TimeZone optional.Option[string]
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
}
func NewActionScheduleSpec(cron string, tz optional.Option[string], referenceTime time.Time) (*ActionScheduleSpec, error) {
spec := &ActionScheduleSpec{
Spec: cron,
TimeZone: tz,
}
cronSchedule, err := spec.Parse()
if err != nil {
return nil, err
}
spec.Next = timeutil.TimeStamp(cronSchedule.Next(referenceTime).Unix())
return spec, nil
}
// Parse parses the spec and returns a cron.Schedule
// Unlike the default cron parser, Parse uses UTC timezone as the default if none is specified.
func (s *ActionScheduleSpec) Parse() (cron.Schedule, error) {
@ -43,19 +59,29 @@ func (s *ActionScheduleSpec) Parse() (cron.Schedule, error) {
return nil, err
}
// If the spec has specified a timezone, use it
if strings.HasPrefix(s.Spec, "TZ=") || strings.HasPrefix(s.Spec, "CRON_TZ=") {
return schedule, nil
}
specSchedule, ok := schedule.(*cron.SpecSchedule)
// If it's not a spec schedule, like "@every 5m", timezone is not relevant
if !ok {
return schedule, nil
}
// Set the timezone to UTC
specSchedule.Location = time.UTC
// If `timezone` is not defined in the workflow, but the spec includes a timezone, use it.
if !s.TimeZone.Has() && (strings.HasPrefix(s.Spec, "TZ=") || strings.HasPrefix(s.Spec, "CRON_TZ=")) {
return schedule, nil
}
var location *time.Location
if present, tz := s.TimeZone.Get(); present {
location, err = time.LoadLocation(tz)
if err != nil {
return nil, err
}
} else {
// UTC is the default time zone.
location = time.UTC
}
specSchedule.Location = location
return specSchedule, nil
}

View file

@ -7,6 +7,8 @@ import (
"testing"
"time"
"forgejo.org/modules/optional"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -21,50 +23,105 @@ func TestActionScheduleSpec_Parse(t *testing.T) {
}()
time.Local = tz
now, err := time.Parse(time.RFC3339, "2024-07-31T15:47:55+08:00")
require.NoError(t, err)
tests := []struct {
name string
spec string
want string
wantErr assert.ErrorAssertionFunc
name string
refTime time.Time
spec string
timeZone string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "regular",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "0 10 * * *",
want: "2024-07-31T10:00:00Z",
wantErr: assert.NoError,
},
{
name: "invalid",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "0 10 * *",
want: "",
wantErr: assert.Error,
},
{
name: "with timezone",
name: "with TZ in cron schedule",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "TZ=America/New_York 0 10 * * *",
want: "2024-07-31T14:00:00Z",
wantErr: assert.NoError,
},
{
name: "timezone irrelevant",
name: "with CRON_TZ in cron schedule",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "CRON_TZ=America/New_York 0 10 * * *",
want: "2024-07-31T14:00:00Z",
wantErr: assert.NoError,
},
{
name: "with separate time zone",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "0 10 * * *",
timeZone: "America/New_York",
want: "2024-07-31T14:00:00Z",
wantErr: assert.NoError,
},
{
name: "separate time zone takes precedence over inlined time zone",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "CRON_TZ=Europe/Berlin 0 10 * * *",
timeZone: "America/New_York",
want: "2024-07-31T14:00:00Z",
wantErr: assert.NoError,
},
{
name: "time zone irrelevant",
refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
spec: "@every 5m",
want: "2024-07-31T07:52:55Z",
wantErr: assert.NoError,
},
{
// The various cron implementations handle the DST jump forwards differently. The most popular approaches
// are (a) scheduling all jobs at 3 o'clock that were supposed to run between 2 and 3 o'clock, or (b)
// skipping the execution on that day because any time between 2 and 3 o'clock never happened. Forgejo uses
// option B because the code it inherited already did that and was exposed to users.
name: "skips execution during DST jump forwards",
refTime: time.Date(2025, 3, 30, 1, 5, 0, 0, time.UTC),
spec: "10 2 * * *", // The clock jumps at 2 o'clock to 3 o'clock.
timeZone: "Europe/Berlin",
want: "2025-03-31T00:10:00Z",
wantErr: assert.NoError,
},
{
name: "executes a first time before DST jump backwards",
refTime: time.Date(2025, 10, 26, 0, 5, 0, 0, time.UTC),
spec: "10 2 * * *", // The clock jumps at 3 o'clock to 2 o'clock.
timeZone: "Europe/Berlin",
want: "2025-10-26T00:10:00Z",
wantErr: assert.NoError,
},
{
name: "executes a second time after DST jump backwards",
refTime: time.Date(2025, 10, 26, 1, 5, 0, 0, time.UTC),
spec: "10 2 * * *", // The clock jumps at 3 o'clock to 2 o'clock.
timeZone: "Europe/Berlin",
want: "2025-10-26T01:10:00Z",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &ActionScheduleSpec{
Spec: tt.spec,
Spec: tt.spec,
TimeZone: optional.FromNonDefault(tt.timeZone),
}
got, err := s.Parse()
tt.wantErr(t, err)
if err == nil {
assert.Equal(t, tt.want, got.Next(now).UTC().Format(time.RFC3339))
assert.Equal(t, tt.want, got.Next(tt.refTime).UTC().Format(time.RFC3339))
}
})
}

View file

@ -0,0 +1,102 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package actions
import (
"testing"
"time"
"forgejo.org/models/db"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/optional"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScheduleCreateScheduleTask(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
user2 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 2})
repo62 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 62, Name: "test_workflows", OwnerID: user2.ID})
content := `
on:
push:
schedule:
- cron: "2 13 * * *"
- cron: "03 13 * * *"
timezone: Europe/Paris
jobs:
test:
runs-on: debian
steps:
- run: |
echo "OK"
`
referenceTime := time.Date(2026, 3, 27, 17, 41, 21, 0, time.UTC)
specWithoutTZ, err := NewActionScheduleSpec("2 13 * * *", optional.None[string](), referenceTime)
require.NoError(t, err)
specWithTZ, err := NewActionScheduleSpec("3 13 * * *", optional.Some("Europe/Paris"), referenceTime)
require.NoError(t, err)
schedule := &ActionSchedule{
Title: ".forgejo/workflows/test.yaml",
Specs: []*ActionScheduleSpec{specWithoutTZ, specWithTZ},
RepoID: repo62.ID,
OwnerID: user2.ID,
WorkflowID: "test.yaml",
WorkflowDirectory: ".forgejo/workflows",
TriggerUserID: -2,
Ref: "main",
CommitSHA: "6af834a5bc97c1a337eb3a21d26903c5cdceca0c",
Event: webhook.HookEventPush,
EventPayload: "{\"action\":\"schedule\"}",
Content: []byte(content),
}
err = CreateScheduleTask(t.Context(), []*ActionSchedule{schedule})
require.NoError(t, err)
schedules, err := db.Find[ActionSchedule](t.Context(), FindScheduleOptions{OwnerID: user2.ID, RepoID: repo62.ID})
require.NoError(t, err)
require.Len(t, schedules, 1)
assert.NotZero(t, schedules[0].ID)
assert.Equal(t, ".forgejo/workflows/test.yaml", schedules[0].Title)
assert.Equal(t, "test.yaml", schedules[0].WorkflowID)
assert.Equal(t, ".forgejo/workflows", schedules[0].WorkflowDirectory)
assert.Equal(t, int64(-2), schedules[0].TriggerUserID)
assert.Equal(t, "main", schedules[0].Ref)
assert.Equal(t, "6af834a5bc97c1a337eb3a21d26903c5cdceca0c", schedules[0].CommitSHA)
assert.Equal(t, webhook.HookEventPush, schedules[0].Event)
assert.JSONEq(t, "{\"action\":\"schedule\"}", schedules[0].EventPayload)
assert.Equal(t, []byte(content), schedules[0].Content)
specs, total, err := FindSpecs(t.Context(), FindSpecOptions{RepoID: repo62.ID})
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.NotZero(t, specs[0].ID)
assert.Equal(t, schedules[0].ID, specs[0].ScheduleID)
assert.Equal(t, timeutil.TimeStamp(1774699380), specs[0].Next)
assert.Equal(t, "3 13 * * *", specs[0].Spec)
assert.Equal(t, optional.Some("Europe/Paris"), specs[0].TimeZone)
assert.Zero(t, specs[0].Prev)
assert.NotZero(t, specs[1].ID)
assert.Equal(t, schedules[0].ID, specs[1].ScheduleID)
assert.Equal(t, timeutil.TimeStamp(1774702920), specs[1].Next)
assert.Equal(t, "2 13 * * *", specs[1].Spec)
assert.Equal(t, optional.None[string](), specs[1].TimeZone)
assert.Zero(t, specs[1].Prev)
}