jojo/tests/integration/actions_trigger_test.go
Andreas Ahlenstorf df86b495dc 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>
2026-04-04 18:23:06 +02:00

1257 lines
41 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"time"
actions_model "forgejo.org/models/actions"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
git_model "forgejo.org/models/git"
issues_model "forgejo.org/models/issues"
repo_model "forgejo.org/models/repo"
unit_model "forgejo.org/models/unit"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
actions_module "forgejo.org/modules/actions"
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
"forgejo.org/modules/test"
webhook_module "forgejo.org/modules/webhook"
actions_service "forgejo.org/services/actions"
issue_service "forgejo.org/services/issue"
pull_service "forgejo.org/services/pull"
release_service "forgejo.org/services/release"
repo_service "forgejo.org/services/repository"
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsPullRequestCommitStatus(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the base repo
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
// prepare the repository
files := make([]*files_service.ChangeRepoFile, 0, 10)
for _, onType := range []string{
"opened",
"synchronize",
"labeled",
"unlabeled",
"assigned",
"unassigned",
"milestoned",
"demilestoned",
"closed",
"reopened",
} {
files = append(files, &files_service.ChangeRepoFile{
Operation: "create",
TreePath: fmt.Sprintf(".forgejo/workflows/%s.yml", onType),
ContentReader: strings.NewReader(fmt.Sprintf(`name: %[1]s
on:
pull_request:
types:
- %[1]s
jobs:
%[1]s:
runs-on: docker
steps:
- run: true
`, onType)),
})
}
baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-pull-request",
[]unit_model.Type{unit_model.TypeActions}, nil, files)
defer f()
baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
require.NoError(t, err)
defer func() {
baseGitRepo.Close()
}()
// prepare the repository labels
labelStr := "/api/v1/repos/user2/repo-pull-request/labels"
labelsCount := 2
labels := make([]*api.Label, labelsCount)
for i := range labelsCount {
color := "abcdef"
req := NewRequestWithJSON(t, "POST", labelStr, &api.CreateLabelOption{
Name: fmt.Sprintf("label%d", i),
Color: color,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
labels[i] = new(api.Label)
DecodeJSON(t, resp, &labels[i])
assert.Equal(t, color, labels[i].Color)
}
// create the pull request
testEditFileToNewBranch(t, session, "user2", "repo-pull-request", "main", "wip-something", "README.md", "Hello, world 1")
testPullCreate(t, session, "user2", "repo-pull-request", true, "main", "wip-something", "Commit status PR")
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: baseRepo.ID})
require.NoError(t, pr.LoadIssue(db.DefaultContext))
// prepare the assignees
issueURL := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%s", "user2", "repo-pull-request", fmt.Sprintf("%d", pr.Issue.Index))
// prepare the labels
labelURL := fmt.Sprintf("%s/labels", issueURL)
// prepare the milestone
milestoneStr := "/api/v1/repos/user2/repo-pull-request/milestones"
req := NewRequestWithJSON(t, "POST", milestoneStr, &api.CreateMilestoneOption{
Title: "mymilestone",
State: "open",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
milestone := new(api.Milestone)
DecodeJSON(t, resp, &milestone)
// check that one of the status associated with the commit sha matches both
// context & state
checkCommitStatus := func(sha, context string, state api.CommitStatusState) bool {
commitStatuses, _, err := git_model.GetLatestCommitStatus(db.DefaultContext, pr.BaseRepoID, sha, db.ListOptionsAll)
require.NoError(t, err)
for _, commitStatus := range commitStatuses {
if state == commitStatus.State && context == commitStatus.Context {
return true
}
}
return false
}
assertActionRun := func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRun *actions_model.ActionRun) {
assert.Equal(t, fmt.Sprintf("%s.yml", onType), actionRun.WorkflowID)
assert.Equal(t, sha, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent)
event, err := actionRun.GetPullRequestEventPayload()
require.NoError(t, err)
assert.Equal(t, action, event.Action)
}
type assertType func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun)
assertActionRuns := func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) {
require.Len(t, actionRuns, 1)
assertActionRun(t, sha, onType, action, actionRuns[0])
}
for _, testCase := range []struct {
onType string
jobID string
doSomething func()
actionRunCount int
action api.HookIssueAction
assert assertType
}{
{
onType: "opened",
doSomething: func() {},
actionRunCount: 1,
action: api.HookIssueOpened,
assert: assertActionRuns,
},
{
onType: "synchronize",
doSomething: func() {
testEditFile(t, session, "user2", "repo-pull-request", "wip-something", "README.md", "Hello, world 2")
},
actionRunCount: 1,
action: api.HookIssueSynchronized,
assert: assertActionRuns,
},
{
onType: "labeled",
doSomething: func() {
req := NewRequestWithJSON(t, "POST", labelURL, &api.IssueLabelsOption{
Labels: []any{labels[0].ID, labels[1].ID},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
},
actionRunCount: 2,
action: api.HookIssueLabelUpdated,
assert: func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) {
assertActionRun(t, sha, onType, api.HookIssueLabelUpdated, actionRuns[0])
assertActionRun(t, sha, onType, api.HookIssueLabelUpdated, actionRuns[1])
},
},
{
onType: "unlabeled",
doSomething: func() {
req := NewRequestWithJSON(t, "PUT", labelURL, &api.IssueLabelsOption{
Labels: []any{labels[0].ID},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
},
actionRunCount: 3,
action: api.HookIssueLabelCleared,
assert: func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) {
foundPayloadWithLabels := false
knownLabels := []string{"label0", "label1"}
for _, actionRun := range actionRuns {
assert.Equal(t, sha, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent)
event, err := actionRun.GetPullRequestEventPayload()
require.NoError(t, err)
switch event.Action {
case api.HookIssueLabelUpdated:
assert.Equal(t, "labeled.yml", actionRun.WorkflowID)
assert.Equal(t, "label0", event.Label.Name)
require.Len(t, event.PullRequest.Labels, 1)
assert.Contains(t, "label0", event.PullRequest.Labels[0].Name)
case api.HookIssueLabelCleared:
assert.Equal(t, "unlabeled.yml", actionRun.WorkflowID)
assert.Contains(t, knownLabels, event.Label.Name)
if len(event.PullRequest.Labels) > 0 {
foundPayloadWithLabels = true
assert.Contains(t, knownLabels, event.PullRequest.Labels[0].Name)
}
default:
require.Fail(t, fmt.Sprintf("unexpected action '%s'", event.Action))
}
}
assert.True(t, foundPayloadWithLabels, "expected at least one clear label payload with non empty labels")
},
},
{
onType: "assigned",
doSomething: func() {
req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{
Assignees: []string{"user2"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
},
actionRunCount: 1,
action: api.HookIssueAssigned,
assert: assertActionRuns,
},
{
onType: "unassigned",
doSomething: func() {
req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{
Assignees: []string{},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
},
actionRunCount: 1,
action: api.HookIssueUnassigned,
assert: assertActionRuns,
},
{
onType: "milestoned",
doSomething: func() {
req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{
Milestone: &milestone.ID,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
},
actionRunCount: 1,
action: api.HookIssueMilestoned,
assert: assertActionRuns,
},
{
onType: "demilestoned",
doSomething: func() {
var zero int64
req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{
Milestone: &zero,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
},
actionRunCount: 1,
action: api.HookIssueDemilestoned,
assert: assertActionRuns,
},
{
onType: "closed",
doSomething: func() {
sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
err = issue_service.ChangeStatus(db.DefaultContext, pr.Issue, user2, sha, true)
require.NoError(t, err)
},
actionRunCount: 1,
action: api.HookIssueClosed,
assert: assertActionRuns,
},
{
onType: "reopened",
doSomething: func() {
sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
err = issue_service.ChangeStatus(db.DefaultContext, pr.Issue, user2, sha, false)
require.NoError(t, err)
},
actionRunCount: 1,
action: api.HookIssueReOpened,
assert: assertActionRuns,
},
} {
t.Run(testCase.onType, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer func() {
// cleanup leftovers, start from scratch
unittest.AssertSuccessfulDelete(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
unittest.AssertSuccessfulDelete(t, &actions_model.ActionRunJob{RepoID: baseRepo.ID})
}()
// trigger the onType event
testCase.doSomething()
count := testCase.actionRunCount
context := fmt.Sprintf("%[1]s / %[1]s (pull_request)", testCase.onType)
var actionRuns []*actions_model.ActionRun
// wait for ActionRun(s) to be created
require.Eventually(t, func() bool {
actionRuns = make([]*actions_model.ActionRun, 0)
require.NoError(t, db.GetEngine(db.DefaultContext).Where("repo_id=?", baseRepo.ID).Find(&actionRuns))
return len(actionRuns) == count
}, 30*time.Second, 1*time.Second)
// verify the expected ActionRuns were created
sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
// verify the commit status changes to CommitStatusSuccess when the job changes to StatusSuccess
require.Eventually(t, func() bool {
return checkCommitStatus(sha, context, api.CommitStatusPending)
}, 30*time.Second, 1*time.Second)
for _, actionRun := range actionRuns {
// verify the expected ActionRunJob was created and is StatusWaiting
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, CommitSHA: sha})
assert.Equal(t, actions_model.StatusWaiting, job.Status)
// change the state of the job to success
job.Status = actions_model.StatusSuccess
actions_service.CreateCommitStatus(db.DefaultContext, job)
}
// verify the commit status changed to CommitStatusSuccess because the job(s) changed to StatusSuccess
require.Eventually(t, func() bool {
return checkCommitStatus(sha, context, api.CommitStatusSuccess)
}, 30*time.Second, 1*time.Second)
testCase.assert(t, sha, testCase.onType, testCase.action, actionRuns)
})
}
})
}
func TestActionsPullRequestWithInvalidWorkflow(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the base repo
session := loginUser(t, "user2")
// prepare the repository
baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-pull-request",
[]unit_model.Type{unit_model.TypeActions}, nil, []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".forgejo/workflows/broken.yml",
ContentReader: strings.NewReader(`name: broken
on:
pull_request:
types:
- opened
jobs:
test:
runs-on: docker
- run: true
`),
},
})
defer f()
baseGitRepo, err := gitrepo.OpenRepository(t.Context(), baseRepo)
require.NoError(t, err)
defer func() {
baseGitRepo.Close()
}()
// create the pull request
testEditFileToNewBranch(t, session, "user2", "repo-pull-request", "main", "wip-something", "README.md", "Hello, world 1")
testPullCreate(t, session, "user2", "repo-pull-request", true, "main", "wip-something", "Commit status PR")
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: baseRepo.ID})
require.NoError(t, pr.LoadIssue(t.Context()))
// check that one of the status associated with the commit sha matches both
// context & state
checkCommitStatus := func(sha, context string, state api.CommitStatusState) bool {
commitStatuses, _, err := git_model.GetLatestCommitStatus(t.Context(), pr.BaseRepoID, sha, db.ListOptionsAll)
require.NoError(t, err)
for _, commitStatus := range commitStatuses {
if state == commitStatus.State && context == commitStatus.Context {
return true
}
}
return false
}
var actionRuns []*actions_model.ActionRun
// wait for ActionRun(s) to be created
require.Eventually(t, func() bool {
actionRuns = make([]*actions_model.ActionRun, 0)
require.NoError(t, db.GetEngine(t.Context()).Where("event=? AND status=? AND repo_id=?", "pull_request", actions_model.StatusFailure, baseRepo.ID).Find(&actionRuns))
return len(actionRuns) == 1
}, 30*time.Second, 1*time.Second)
// verify the expected ActionRuns were created
sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
require.NoError(t, err)
// verify the commit status changes to CommitStatusFailure
require.Eventually(t, func() bool {
return checkCommitStatus(sha, "broken.yml / Update README.md (pull_request)", api.CommitStatusFailure)
}, 30*time.Second, 1*time.Second)
require.Len(t, actionRuns, 1)
actionRun := actionRuns[0]
// verify the expected ActionRunJob was created and is StatusFailure
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, CommitSHA: sha})
assert.Equal(t, actions_model.StatusFailure, job.Status)
assert.Equal(t, "broken.yml", actionRun.WorkflowID)
assert.Equal(t, sha, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent)
event, err := actionRun.GetPullRequestEventPayload()
require.NoError(t, err)
assert.Equal(t, api.HookIssueOpened, event.Action)
})
}
func TestActionsPullRequestTargetEvent(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the base repo
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the forked repo
// create the base repo
baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-pull-request-target",
[]unit_model.Type{unit_model.TypeActions}, nil, nil,
)
defer f()
// create the forked repo
forkedRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, user2, org3, repo_service.ForkRepoOptions{
BaseRepo: baseRepo,
Name: "forked-repo-pull-request-target",
Description: "test pull-request-target event",
})
require.NoError(t, err)
assert.NotEmpty(t, forkedRepo)
// add workflow file to the base repo
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/pr.yml",
ContentReader: strings.NewReader("name: test\non:\n pull_request_target:\n paths:\n - 'file_*.txt'\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
},
},
Message: "add workflow",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Committer: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
assert.NotEmpty(t, addWorkflowToBaseResp)
// add a new file to the forked repo
addFileToForkedResp, err := files_service.ChangeRepoFiles(git.DefaultContext, forkedRepo, org3, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "file_1.txt",
ContentReader: strings.NewReader("file1"),
},
},
Message: "add file1",
OldBranch: "main",
NewBranch: "fork-branch-1",
Author: &files_service.IdentityOptions{
Name: org3.Name,
Email: org3.Email,
},
Committer: &files_service.IdentityOptions{
Name: org3.Name,
Email: org3.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
assert.NotEmpty(t, addFileToForkedResp)
// create Pull
pullIssue := &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "Test pull-request-target-event",
PosterID: org3.ID,
Poster: org3,
IsPull: true,
}
pullRequest := &issues_model.PullRequest{
HeadRepoID: forkedRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "fork-branch-1",
BaseBranch: "main",
HeadRepo: forkedRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
require.NoError(t, err)
// if a PR "synchronized" event races the "opened" event by having the same SHA, it must be skipped. See https://codeberg.org/forgejo/forgejo/issues/2009.
assert.True(t, actions_service.SkipPullRequestEvent(git.DefaultContext, webhook_module.HookEventPullRequestSync, baseRepo.ID, addFileToForkedResp.Commit.SHA))
// load and compare ActionRun
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(git.DefaultContext, forkedRepo, org3, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "foo.txt",
ContentReader: strings.NewReader("foo"),
},
},
Message: "add foo.txt",
OldBranch: "main",
NewBranch: "fork-branch-2",
Author: &files_service.IdentityOptions{
Name: org3.Name,
Email: org3.Email,
},
Committer: &files_service.IdentityOptions{
Name: org3.Name,
Email: org3.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
assert.NotEmpty(t, addFileToForkedResp)
// create Pull
pullIssue = &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "A mismatched path cannot trigger pull-request-target-event",
PosterID: org3.ID,
Poster: org3,
IsPull: true,
}
pullRequest = &issues_model.PullRequest{
HeadRepoID: forkedRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "fork-branch-2",
BaseBranch: "main",
HeadRepo: forkedRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
require.NoError(t, err)
// the new pull request cannot trigger actions, so there is still only 1 record
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
})
}
func TestActionsSkipCI(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2")
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, _, f := tests.CreateDeclarativeRepo(t, user2, "skip-ci",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/pr.yml",
ContentReader: strings.NewReader("name: test\non:\n push:\n branches: [main]\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
},
},
)
defer f()
// a run has been created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
// add a file with a configured skip-ci string in commit message
addFileResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "bar.txt",
ContentReader: strings.NewReader("bar"),
},
},
Message: fmt.Sprintf("%s add bar", setting.Actions.SkipWorkflowStrings[0]),
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Committer: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
assert.NotEmpty(t, addFileResp)
// the commit message contains a configured skip-ci string, so there is still only 1 record
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
// add file to new branch
addFileToBranchResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "test-skip-ci",
ContentReader: strings.NewReader("test-skip-ci"),
},
},
Message: "add test file",
OldBranch: "main",
NewBranch: "test-skip-ci",
Author: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Committer: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
assert.NotEmpty(t, addFileToBranchResp)
resp := testPullCreate(t, session, "user2", "skip-ci", true, "main", "test-skip-ci", "[skip ci] test-skip-ci")
// check the redirected URL
url := test.RedirectURL(resp)
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
// the pr title contains a configured skip-ci string, so there is still only 1 record
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
}
func TestActionsCreateDeleteRefEvent(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
Name: "create-delete-ref-event",
Description: "test create delete ref ci event",
AutoInit: true,
Gitignores: "Go",
License: "MIT",
Readme: "Default",
DefaultBranch: "main",
IsPrivate: false,
})
require.NoError(t, err)
assert.NotEmpty(t, repo)
// enable actions
err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
RepoID: repo.ID,
Type: unit_model.TypeActions,
}}, nil)
require.NoError(t, err)
// add workflow file to the repo
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/createdelete.yml",
ContentReader: strings.NewReader("name: test\non:\n [create,delete]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
},
},
Message: "add workflow",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Committer: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
assert.NotEmpty(t, addWorkflowToBaseResp)
// Get the commit ID of the default branch
gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
require.NoError(t, err)
// create a branch
err = repo_service.CreateNewBranchFromCommit(db.DefaultContext, user2, repo, gitRepo, branch.CommitID, "test-create-branch")
require.NoError(t, err)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
Title: "add workflow",
RepoID: repo.ID,
Event: "create",
Ref: "refs/heads/test-create-branch",
WorkflowID: "createdelete.yml",
CommitSHA: branch.CommitID,
})
assert.NotNil(t, run)
// create a tag
err = release_service.CreateNewTag(db.DefaultContext, user2, repo, branch.CommitID, "test-create-tag", "test create tag event")
require.NoError(t, err)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
Title: "add workflow",
RepoID: repo.ID,
Event: "create",
Ref: "refs/tags/test-create-tag",
WorkflowID: "createdelete.yml",
CommitSHA: branch.CommitID,
})
assert.NotNil(t, run)
// delete the branch
err = repo_service.DeleteBranch(db.DefaultContext, user2, repo, gitRepo, "test-create-branch")
require.NoError(t, err)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
Title: "add workflow",
RepoID: repo.ID,
Event: "delete",
Ref: "refs/heads/main",
WorkflowID: "createdelete.yml",
CommitSHA: branch.CommitID,
})
assert.NotNil(t, run)
// delete the tag
tag, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "test-create-tag")
require.NoError(t, err)
err = release_service.DeleteReleaseByID(db.DefaultContext, repo, tag, user2, true)
require.NoError(t, err)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
Title: "add workflow",
RepoID: repo.ID,
Event: "delete",
Ref: "refs/heads/main",
WorkflowID: "createdelete.yml",
CommitSHA: branch.CommitID,
})
assert.NotNil(t, run)
})
}
func TestActionsWorkflowDispatch(t *testing.T) {
testCases := []struct {
name string
workflowID string
workflowDirectory string
}{
{
name: "GitHub",
workflowID: "dispatch.yml",
workflowDirectory: ".github/workflows",
},
{
name: "Gitea",
workflowID: "test.yml",
workflowDirectory: ".gitea/workflows",
},
{
name: "Forgejo",
workflowID: "build.yml",
workflowDirectory: ".forgejo/workflows",
},
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: fmt.Sprintf("%s/%s", testCase.workflowDirectory, testCase.workflowID),
ContentReader: strings.NewReader(
"name: test\n" +
"on: [workflow_dispatch]\n" +
"jobs:\n" +
" test:\n" +
" runs-on: ubuntu-latest\n" +
" steps:\n" +
" - run: echo helloworld\n",
),
},
},
)
defer f()
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", testCase.workflowID)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", workflow.Ref)
assert.Equal(t, sha, workflow.Commit.ID.String())
inputGetter := func(key string) string {
return ""
}
var r *actions_model.ActionRun
var j []string
r, j, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
require.NoError(t, err)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assert.Equal(t, "test", r.Title)
assert.Equal(t, testCase.workflowID, r.WorkflowID)
assert.Equal(t, testCase.workflowDirectory, r.WorkflowDirectory)
assert.Equal(t, sha, r.CommitSHA)
assert.Equal(t, actions_module.GithubEventWorkflowDispatch, r.TriggerEvent)
assert.Len(t, j, 1)
assert.Equal(t, "test", j[0])
})
}
})
}
func TestActionsWorkflowDispatchRejectsInputsThatExceedLimit(t *testing.T) {
workflow := `
name: test
on:
workflow_dispatch:
inputs:
boolean:
description: 'Boolean'
type: boolean
number:
description: 'Number'
default: '100'
type: number
string:
description: 'String'
type: string
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "OK"
`
defer test.MockVariableValue(&setting.Actions.LimitDispatchInputs, 2)()
testCases := []struct {
name string
inputs map[string]string
expectedError string
}{
{
name: "below-limit",
inputs: map[string]string{"boolean": "true", "number": "10"},
},
{
name: "beyond-limit",
inputs: map[string]string{"boolean": "true", "number": "10", "string": "my input"},
expectedError: "too many inputs",
},
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".forgejo/workflows/dispatch.yaml",
ContentReader: strings.NewReader(workflow),
},
},
)
defer f()
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yaml")
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", workflow.Ref)
assert.Equal(t, sha, workflow.Commit.ID.String())
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
inputGetter := func(key string) string {
return testCase.inputs[key]
}
_, _, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
if testCase.expectedError == "" {
require.NoError(t, err)
} else {
assert.EqualError(t, err, testCase.expectedError)
}
})
}
})
}
func TestActionsWorkflowDispatchDynamicMatrix(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(
"name: test\n" +
"on: [workflow_dispatch]\n" +
"jobs:\n" +
" test:\n" +
" runs-on: ubuntu-latest\n" +
" strategy:\n" +
" matrix: \n" +
" dim1: \"${{ fromJSON(needs.other-job.outputs.some-output) }}\"\n" +
" steps:\n" +
" - run: echo helloworld\n",
),
},
},
)
defer f()
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml")
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", workflow.Ref)
assert.Equal(t, sha, workflow.Commit.ID.String())
inputGetter := func(key string) string {
return ""
}
run, _, err := workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
require.NoError(t, err)
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
assert.Contains(t, string(job.WorkflowPayload), "incomplete_matrix: true")
})
}
func TestActionsWorkflowDispatchReusableWorkflow(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".forgejo/workflows/dispatch.yml",
ContentReader: strings.NewReader(
"name: test\n" +
"on: [workflow_dispatch]\n" +
"jobs:\n" +
" test:\n" +
" uses: ./.forgejo/workflows/reusable.yml\n",
),
},
{
Operation: "create",
TreePath: ".forgejo/workflows/reusable.yml",
ContentReader: strings.NewReader(
"name: test\n" +
"on: [workflow_call]\n" +
"jobs:\n" +
" inner:\n" +
" runs-on: ubuntu-latest\n" +
" steps:\n" +
" - run: echo helloworld\n",
),
},
},
)
defer f()
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml")
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", workflow.Ref)
assert.Equal(t, sha, workflow.Commit.ID.String())
inputGetter := func(key string) string {
return ""
}
run, _, err := workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
require.NoError(t, err)
var runJobs []*actions_model.ActionRunJob
db.GetEngine(t.Context()).Where("run_id=?", run.ID).Find(&runJobs)
assert.Len(t, runJobs, 2)
var parentJob *actions_model.ActionRunJob
var childJob *actions_model.ActionRunJob
for _, j := range runJobs {
switch j.JobID {
case "test":
parentJob = j
case "test.inner":
childJob = j
}
}
assert.NotNil(t, parentJob, "parentJob")
assert.NotNil(t, childJob, "childJob")
})
}
func TestActionsWorkflowDispatchConcurrencyGroup(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(
"name: test\n" +
"on: [workflow_dispatch]\n" +
"jobs:\n" +
" test:\n" +
" runs-on: ubuntu-latest\n" +
" steps:\n" +
" - run: echo helloworld\n" +
"concurrency:\n" +
" group: workflow-magic-group\n" +
" cancel-in-progress: true\n",
),
},
},
)
defer f()
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml")
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", workflow.Ref)
assert.Equal(t, sha, workflow.Commit.ID.String())
inputGetter := func(key string) string {
return ""
}
firstRun, _, err := workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
require.NoError(t, err)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assert.Equal(t, "workflow-magic-group", firstRun.ConcurrencyGroup)
assert.Equal(t, actions_model.CancelInProgress, firstRun.ConcurrencyType)
// Dispatch again and verify previous run was cancelled:
secondRun, _, err := workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
require.NoError(t, err)
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assert.Equal(t, "workflow-magic-group", secondRun.ConcurrencyGroup)
assert.Equal(t, actions_model.CancelInProgress, secondRun.ConcurrencyType)
firstRunReload := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: firstRun.ID})
assert.Equal(t, actions_model.StatusCancelled, firstRunReload.Status)
})
}
func TestActionsScheduledWorkflow(t *testing.T) {
type expectedSpec struct {
cron string
timeZone optional.Option[string]
}
testCases := []struct {
name string
workflowID string
workflowDirectory string
workflowContent string
expectedWorkflowTitle string
expectedCronSpecs []expectedSpec
}{
{
name: "GitHub",
workflowID: "scheduled.yml",
workflowDirectory: ".github/workflows",
workflowContent: `
on:
schedule:
- cron: "30 5,17 * * *"
jobs:
test:
steps:
- run: echo OK
`,
expectedWorkflowTitle: ".github/workflows/scheduled.yml",
expectedCronSpecs: []expectedSpec{{cron: "30 5,17 * * *", timeZone: optional.None[string]()}},
},
{
name: "Gitea",
workflowID: "test.yml",
workflowDirectory: ".gitea/workflows",
workflowContent: `
name: My scheduled workflow
on:
schedule:
- cron: "* * * * *"
jobs:
test:
steps:
- run: echo OK
`,
expectedWorkflowTitle: "My scheduled workflow",
expectedCronSpecs: []expectedSpec{{cron: "* * * * *", timeZone: optional.None[string]()}},
},
{
name: "Forgejo with time zone",
workflowID: "tz.yml",
workflowDirectory: ".forgejo/workflows",
workflowContent: `
on:
schedule:
- cron: "44 10 * * *"
- cron: "25 19 * * *"
timezone: Europe/Madrid
jobs:
test:
steps:
- run: echo OK
`,
expectedWorkflowTitle: ".forgejo/workflows/tz.yml",
expectedCronSpecs: []expectedSpec{
{cron: "44 10 * * *", timeZone: optional.None[string]()},
{cron: "25 19 * * *", timeZone: optional.Some("Europe/Madrid")},
},
},
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// create the repo
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
[]unit_model.Type{unit_model.TypeActions}, nil,
[]*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: fmt.Sprintf("%s/%s", testCase.workflowDirectory, testCase.workflowID),
ContentReader: strings.NewReader(testCase.workflowContent),
},
},
)
defer f()
schedules, err := db.Find[actions_model.ActionSchedule](t.Context(), actions_model.FindScheduleOptions{RepoID: repo.ID})
require.NoError(t, err)
require.Len(t, schedules, 1)
assert.Equal(t, testCase.expectedWorkflowTitle, schedules[0].Title)
assert.Equal(t, repo.ID, schedules[0].RepoID)
assert.Equal(t, repo.OwnerID, schedules[0].OwnerID)
assert.Equal(t, testCase.workflowID, schedules[0].WorkflowID)
assert.Equal(t, testCase.workflowDirectory, schedules[0].WorkflowDirectory)
assert.Equal(t, int64(-2), schedules[0].TriggerUserID)
assert.Equal(t, sha, schedules[0].CommitSHA)
assert.Equal(t, webhook_module.HookEventPush, schedules[0].Event)
assert.Equal(t, []byte(testCase.workflowContent), schedules[0].Content)
specs, total, err := actions_model.FindSpecs(t.Context(), actions_model.FindSpecOptions{RepoID: repo.ID})
require.NoError(t, err)
assert.Equal(t, int64(len(testCase.expectedCronSpecs)), total)
// The query to return cron specs orders by `id DESC`.
slices.Reverse(testCase.expectedCronSpecs)
for i, expected := range testCase.expectedCronSpecs {
assert.Equal(t, schedules[0].ID, specs[i].ScheduleID)
assert.Equal(t, expected.cron, specs[i].Spec)
assert.Equal(t, expected.timeZone, specs[i].TimeZone)
}
})
}
})
}