// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: GPL-3.0-or-later package actions import ( "context" "testing" "time" actions_model "forgejo.org/models/actions" "forgejo.org/models/db" repo_model "forgejo.org/models/repo" "forgejo.org/models/unit" "forgejo.org/models/unittest" "forgejo.org/modules/optional" "forgejo.org/modules/test" "forgejo.org/modules/timeutil" webhook_module "forgejo.org/modules/webhook" "code.forgejo.org/forgejo/runner/v12/act/jobparser" "code.forgejo.org/forgejo/runner/v12/act/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestServiceActions_startTask(t *testing.T) { defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")() require.NoError(t, unittest.PrepareTestDatabase()) // Load fixtures that are corrupted and create one valid scheduled workflow repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) spec, err := actions_model.NewActionScheduleSpec("* * * * *", optional.None[string](), time.Now()) require.NoError(t, err) workflowID := "some.yml" schedules := []*actions_model.ActionSchedule{ { Title: "scheduletitle1", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: workflowID, WorkflowDirectory: ".forgejo/workflows", TriggerUserID: repo.OwnerID, Ref: "branch", CommitSHA: "fakeSHA", Event: webhook_module.HookEventSchedule, EventPayload: "fakepayload", Specs: []*actions_model.ActionScheduleSpec{spec}, Content: []byte( ` jobs: job2: runs-on: ubuntu-latest steps: - run: true `), }, } require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules)) require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1") require.NoError(t, err) // After running startTasks an ActionRun row is created for the valid scheduled workflow require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) require.NoError(t, startTasks(t.Context())) require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) // The invalid workflows loaded from the fixtures are disabled repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) actionUnit, err := repo.GetUnit(t.Context(), unit.TypeActions) require.NoError(t, err) actionConfig := actionUnit.ActionsConfig() assert.True(t, actionConfig.IsWorkflowDisabled("workflow2.yml")) assert.True(t, actionConfig.IsWorkflowDisabled("workflow1.yml")) assert.False(t, actionConfig.IsWorkflowDisabled("some.yml")) } func TestCreateScheduleTask(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2}) assertConstant := func(t *testing.T, cron *actions_model.ActionSchedule, run *actions_model.ActionRun) { t.Helper() assert.Equal(t, cron.Title, run.Title) assert.Equal(t, cron.RepoID, run.RepoID) assert.Equal(t, cron.OwnerID, run.OwnerID) assert.Equal(t, cron.WorkflowID, run.WorkflowID) assert.Equal(t, cron.WorkflowDirectory, run.WorkflowDirectory) assert.Equal(t, cron.TriggerUserID, run.TriggerUserID) assert.Equal(t, cron.Ref, run.Ref) assert.Equal(t, cron.CommitSHA, run.CommitSHA) assert.Equal(t, cron.Event, run.Event) assert.Equal(t, cron.EventPayload, run.EventPayload) assert.Equal(t, cron.ID, run.ScheduleID) assert.Equal(t, actions_model.StatusWaiting, run.Status) assert.Equal(t, "branch_some.yml_schedule__auto", run.ConcurrencyGroup) assert.Equal(t, actions_model.UnlimitedConcurrency, run.ConcurrencyType) } assertMutable := func(t *testing.T, expected, run *actions_model.ActionRun) { t.Helper() assert.Equal(t, expected.NotifyEmail, run.NotifyEmail) } testCases := []struct { name string cron actions_model.ActionSchedule want []actions_model.ActionRun }{ { name: "simple", cron: actions_model.ActionSchedule{ Title: "scheduletitle1", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "some.yml", WorkflowDirectory: ".forgejo/workflows", TriggerUserID: repo.OwnerID, Ref: "branch", CommitSHA: "fakeSHA", Event: webhook_module.HookEventSchedule, EventPayload: "fakepayload", Content: []byte( ` name: test on: push jobs: job2: runs-on: ubuntu-latest steps: - run: true `), }, want: []actions_model.ActionRun{ { Title: "scheduletitle1", NotifyEmail: false, }, }, }, { name: "enable-email-notifications is true", cron: actions_model.ActionSchedule{ Title: "scheduletitle2", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "some.yml", WorkflowDirectory: ".github/workflows", TriggerUserID: repo.OwnerID, Ref: "branch", CommitSHA: "fakeSHA", Event: webhook_module.HookEventSchedule, EventPayload: "fakepayload", Content: []byte( ` name: test enable-email-notifications: true on: push jobs: job2: runs-on: ubuntu-latest steps: - run: true `), }, want: []actions_model.ActionRun{ { Title: "scheduletitle2", NotifyEmail: true, }, }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { require.NoError(t, CreateScheduleTask(t.Context(), &testCase.cron)) require.Equal(t, len(testCase.want), unittest.GetCount(t, actions_model.ActionRun{RepoID: repo.ID})) for _, expected := range testCase.want { run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{Title: expected.Title}) assertConstant(t, &testCase.cron, run) assertMutable(t, &expected, run) } unittest.AssertSuccessfulDelete(t, actions_model.ActionRun{RepoID: repo.ID}) }) } } func TestCancelPreviousJobs(t *testing.T) { defer unittest.OverrideFixtures("services/actions/TestCancelPreviousJobs")() require.NoError(t, unittest.PrepareTestDatabase()) run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 894}) assert.Equal(t, actions_model.StatusRunning, run.Status) assert.EqualValues(t, 1683636626, run.Updated) runJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: 894}) assert.Equal(t, actions_model.StatusRunning, runJob.Status) assert.EqualValues(t, 1683636528, runJob.Started) err := CancelPreviousJobs(t.Context(), 63, "refs/heads/main", "running.yaml", webhook_module.HookEventWorkflowDispatch) require.NoError(t, err) run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 894}) assert.Equal(t, actions_model.StatusCancelled, run.Status) assert.Greater(t, run.Updated, timeutil.TimeStamp(1683636626)) runJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: 894}) assert.Equal(t, actions_model.StatusCancelled, runJob.Status) assert.Greater(t, runJob.Stopped, timeutil.TimeStamp(1683636528)) } func TestCancelPreviousWithConcurrencyGroup(t *testing.T) { for _, tc := range []struct { name string updateRun901 map[string]any updateRun901Jobs map[string]any expected901Status actions_model.Status }{ // run 900 & 901 in the fixture data have almost the same data and so should both be cancelled by // TestCancelPreviousWithConcurrencyGroup -- but each test case will vary something different about 601 to // ensure that only run 600 is targeted by the cancellation { name: "only cancels target repo", updateRun901: map[string]any{"repo_id": 2}, }, { name: "only cancels target concurrency group", updateRun901: map[string]any{"concurrency_group": "321cba"}, }, { name: "only cancels running", updateRun901: map[string]any{"status": actions_model.StatusSuccess}, updateRun901Jobs: map[string]any{"status": actions_model.StatusSuccess}, expected901Status: actions_model.StatusSuccess, }, { name: "cancels running job in failed run", updateRun901: map[string]any{"status": actions_model.StatusFailure}, // still has a running job in it (601), but run is marked as failed as-if another job had failed in it expected901Status: actions_model.StatusCancelled, }, } { t.Run(tc.name, func(t *testing.T) { defer unittest.OverrideFixtures("services/actions/TestCancelPreviousWithConcurrencyGroup")() require.NoError(t, unittest.PrepareTestDatabase()) e := db.GetEngine(t.Context()) expected901Status := actions_model.StatusRunning if tc.updateRun901 != nil { affected, err := e.Table(&actions_model.ActionRun{}).Where("id = ?", 901).Update(tc.updateRun901) require.NoError(t, err) require.EqualValues(t, 1, affected) } if tc.updateRun901Jobs != nil { affected, err := e.Table(&actions_model.ActionRunJob{}).Where("run_id = ?", 901).Update(tc.updateRun901Jobs) require.NoError(t, err) require.EqualValues(t, 1, affected) } if tc.expected901Status != actions_model.StatusUnknown { expected901Status = tc.expected901Status } run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 900}) assert.Equal(t, actions_model.StatusRunning, run.Status) assert.EqualValues(t, 1683636626, run.Updated) assert.Equal(t, "abc123", run.ConcurrencyGroup) runJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: 900}) assert.Equal(t, actions_model.StatusRunning, runJob.Status) assert.EqualValues(t, 1683636528, runJob.Started) // Search for concurrency group should be case-insensitive, which we test here by using a different capitalization // than the fixture data err := CancelPreviousWithConcurrencyGroup(t.Context(), 63, "ABC123") require.NoError(t, err) run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 900}) assert.Equal(t, actions_model.StatusCancelled, run.Status) assert.Greater(t, run.Updated, timeutil.TimeStamp(1683636626)) runJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: 900}) assert.Equal(t, actions_model.StatusCancelled, runJob.Status) assert.Greater(t, runJob.Stopped, timeutil.TimeStamp(1683636528)) run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 901}) assert.Equal(t, expected901Status, run.Status) }) } } func TestServiceActions_DynamicMatrix(t *testing.T) { defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")() require.NoError(t, unittest.PrepareTestDatabase()) // Load fixtures that are corrupted and create one valid scheduled workflow repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) spec, err := actions_model.NewActionScheduleSpec("* * * * *", optional.None[string](), time.Now()) require.NoError(t, err) workflowID := "some.yml" schedules := []*actions_model.ActionSchedule{ { Title: "scheduletitle1", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: workflowID, WorkflowDirectory: ".forgejo/workflows", TriggerUserID: repo.OwnerID, Ref: "branch", CommitSHA: "fakeSHA", Event: webhook_module.HookEventSchedule, EventPayload: "fakepayload", Specs: []*actions_model.ActionScheduleSpec{spec}, Content: []byte( ` jobs: job2: runs-on: ubuntu-latest strategy: matrix: dim1: "${{ fromJSON(needs.other-job.outputs.some-output) }}" steps: - run: true `), }, } require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules)) require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1") require.NoError(t, err) // After running startTasks an ActionRun row is created for the valid scheduled workflow require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) require.NoError(t, startTasks(t.Context())) require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ WorkflowID: workflowID, }) require.NoError(t, err) require.Len(t, runs, 1) run := runs[0] jobs, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: run.ID}) require.NoError(t, err) require.Len(t, jobs, 1) job := jobs[0] // With a matrix that contains ${{ needs ... }} references, the only requirement to work is that when the job is // first inserted it is tagged w/ incomplete_matrix assert.Contains(t, string(job.WorkflowPayload), "incomplete_matrix: true") } func TestServiceActions_RunsOnNeeds(t *testing.T) { defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")() require.NoError(t, unittest.PrepareTestDatabase()) // Load fixtures that are corrupted and create one valid scheduled workflow repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) spec, err := actions_model.NewActionScheduleSpec("* * * * *", optional.None[string](), time.Now()) require.NoError(t, err) workflowID := "some.yml" schedules := []*actions_model.ActionSchedule{ { Title: "scheduletitle1", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: workflowID, TriggerUserID: repo.OwnerID, Ref: "branch", CommitSHA: "fakeSHA", Event: webhook_module.HookEventSchedule, EventPayload: "fakepayload", Specs: []*actions_model.ActionScheduleSpec{spec}, Content: []byte( ` jobs: job2: runs-on: "${{ fromJSON(needs.other-job.outputs.some-output) }}" steps: - run: true `), }, } require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules)) require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1") require.NoError(t, err) // After running startTasks an ActionRun row is created for the valid scheduled workflow require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) require.NoError(t, startTasks(t.Context())) require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ WorkflowID: workflowID, }) require.NoError(t, err) require.Len(t, runs, 1) run := runs[0] jobs, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: run.ID}) require.NoError(t, err) require.Len(t, jobs, 1) job := jobs[0] // With a runs-on that contains ${{ needs ... }} references, the only requirement to work is that when the job is // first inserted it is tagged w/ incomplete_runs_on assert.Contains(t, string(job.WorkflowPayload), "incomplete_runs_on: true") } func TestServiceActions_ExpandReusableWorkflow(t *testing.T) { defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")() require.NoError(t, unittest.PrepareTestDatabase()) type callArgs struct { repoID int64 commitSHA string path string } var localReusableCalled []*callArgs var cleanupCallCount int defer test.MockVariableValue(&lazyRepoExpandLocalReusableWorkflow, func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) { fetcher := func(job *jobparser.Job, path string) ([]byte, error) { localReusableCalled = append(localReusableCalled, &callArgs{repoID, commitSHA, path}) return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil } cleanup := func() { cleanupCallCount++ } return fetcher, cleanup })() remoteReusableCalled := []*model.NonLocalReusableWorkflowReference{} defer test.MockVariableValue(&expandInstanceReusableWorkflows, func(ctx context.Context) jobparser.InstanceWorkflowFetcher { return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) { remoteReusableCalled = append(remoteReusableCalled, ref) return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil } })() // Load fixtures that are corrupted and create one valid scheduled workflow repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) spec, err := actions_model.NewActionScheduleSpec("* * * * *", optional.None[string](), time.Now()) require.NoError(t, err) workflowID := "some.yml" schedules := []*actions_model.ActionSchedule{ { Title: "scheduletitle1", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: workflowID, TriggerUserID: repo.OwnerID, Ref: "branch", CommitSHA: "fakeSHA", Event: webhook_module.HookEventSchedule, EventPayload: "fakepayload", Specs: []*actions_model.ActionScheduleSpec{spec}, Content: []byte( ` jobs: job2: uses: ./.forgejo/workflows/reusable.yml job3: uses: some-org/some-repo/.forgejo/workflows/reusable-path.yml@main `), }, } require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules)) require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1") require.NoError(t, err) // After running startTasks an ActionRun row is created for the valid scheduled workflow require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) require.NoError(t, startTasks(t.Context())) require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ WorkflowID: workflowID, }) require.NoError(t, err) require.Len(t, runs, 1) run := runs[0] assert.EqualValues(t, 0, run.PreExecutionErrorCode, "pre execution error details: %#v", run.PreExecutionErrorDetails) require.Len(t, localReusableCalled, 1, "localReusableCalled") require.Len(t, remoteReusableCalled, 1, "remoteReusableCalled") assert.Equal(t, &callArgs{4, "fakeSHA", "./.forgejo/workflows/reusable.yml"}, localReusableCalled[0]) assert.Equal(t, 2, cleanupCallCount, "cleanupCallCount") assert.Equal(t, &model.NonLocalReusableWorkflowReference{ Org: "some-org", Repo: "some-repo", Filename: "reusable-path.yml", Ref: "main", GitPlatform: "forgejo", }, remoteReusableCalled[0]) }