From c7b99c8cc7c7bfaf0e54b9528eb594b17809475a Mon Sep 17 00:00:00 2001 From: NorthRealm <155140859+NorthRealm@users.noreply.github.com> Date: Mon, 25 Aug 2025 00:30:56 +0800 Subject: [PATCH] Prevent duplicate actions email (#35215) Trying to prevent duplicate action emails by adding an extra check on job status. --------- Signed-off-by: NorthRealm <155140859+NorthRealm@users.noreply.github.com> Co-authored-by: Christopher Homberger --- routers/web/repo/actions/view.go | 13 +- services/actions/clear_tasks.go | 30 +- services/mailer/mail_workflow_run.go | 39 +- services/mailer/notify.go | 5 +- tests/integration/repo_webhook_test.go | 471 +++++++++++++++++++++++-- 5 files changed, 503 insertions(+), 55 deletions(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 52b2e9995e..3422128026 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -429,6 +429,12 @@ func Rerun(ctx *context_module.Context) { ctx.ServerError("UpdateRun", err) return } + + if err := run.LoadAttributes(ctx); err != nil { + ctx.ServerError("run.LoadAttributes", err) + return + } + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) } job, jobs := getRunJobs(ctx, runIndex, jobIndex) @@ -485,7 +491,6 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou } actions_service.CreateCommitStatus(ctx, job) - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) return nil @@ -560,9 +565,8 @@ func Cancel(ctx *context_module.Context) { if len(updatedjobs) > 0 { job := updatedjobs[0] actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) } - ctx.JSON(http.StatusOK, struct{}{}) + ctx.JSONOK() } func Approve(ctx *context_module.Context) { @@ -606,7 +610,6 @@ func Approve(ctx *context_module.Context) { if len(updatedjobs) > 0 { job := updatedjobs[0] actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) } for _, job := range updatedjobs { @@ -614,7 +617,7 @@ func Approve(ctx *context_module.Context) { notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } - ctx.JSON(http.StatusOK, struct{}{}) + ctx.JSONOK() } func Delete(ctx *context_module.Context) { diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index 274c04aa57..3c7aa0b1a5 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -42,10 +42,8 @@ func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.Ac _ = job.LoadAttributes(ctx) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } - if len(jobs) > 0 { - job := jobs[0] - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) - } + job := jobs[0] + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) } } @@ -101,7 +99,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { return nil } -// CancelAbandonedJobs cancels the jobs which have waiting status, but haven't been picked by a runner for a long time +// CancelAbandonedJobs cancels jobs that have not been picked by any runner for a long time func CancelAbandonedJobs(ctx context.Context) error { jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{ Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked}, @@ -113,24 +111,40 @@ func CancelAbandonedJobs(ctx context.Context) error { } now := timeutil.TimeStampNow() + + // Collect one job per run to send workflow run status update + updatedRuns := map[int64]*actions_model.ActionRunJob{} + for _, job := range jobs { job.Status = actions_model.StatusCancelled job.Stopped = now updated := false if err := db.WithTx(ctx, func(ctx context.Context) error { n, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped") - updated = err == nil && n > 0 - return err + if err != nil { + return err + } + if err := job.LoadAttributes(ctx); err != nil { + return err + } + updated = n > 0 + if updated && job.Run.Status.IsDone() { + updatedRuns[job.RunID] = job + } + return nil }); err != nil { log.Warn("cancel abandoned job %v: %v", job.ID, err) // go on } CreateCommitStatus(ctx, job) if updated { - NotifyWorkflowRunStatusUpdateWithReload(ctx, job) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } } + for _, job := range updatedRuns { + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } + return nil } diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go index 29b3abda8e..62e8552159 100644 --- a/services/mailer/mail_workflow_run.go +++ b/services/mailer/mail_workflow_run.go @@ -33,7 +33,18 @@ func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Reposito return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain) } -func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) { +func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) error { + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return err + } + for _, job := range jobs { + if !job.Status.IsDone() { + log.Debug("composeAndSendActionsWorkflowRunStatusEmail: A job is not done. Will not compose and send actions email.") + return nil + } + } + subject := "Run" switch run.Status { case actions_model.StatusFailure: @@ -48,11 +59,6 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run) metadataHeaders := generateMetadataHeaders(repo) - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) - if err != nil { - log.Error("GetRunJobsByRunID: %v", err) - return - } sort.SliceStable(jobs, func(i, j int) bool { si, sj := jobs[i].Status, jobs[j].Status /* @@ -111,11 +117,11 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo "Jobs": convertedJobs, "locale": locale, }); err != nil { - log.Error("ExecuteTemplate [%s]: %v", tplWorkflowRun, err) - return + return err } msgs := make([]*sender_service.Message, 0, len(tos)) for _, rec := range tos { + log.Trace("Sending actions email to %s (UID: %d)", rec.Name, rec.ID) msg := sender_service.NewMessageFrom( rec.Email, displayName, @@ -135,14 +141,16 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo } SendAsync(msgs...) } + + return nil } -func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) { +func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) error { if setting.MailService == nil { - return + return nil } - if run.Status.IsSkipped() { - return + if !run.Status.IsDone() || run.Status.IsSkipped() { + return nil } recipients := make([]*user_model.User, 0) @@ -151,8 +159,7 @@ func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo notifyPref, err := user_model.GetUserSetting(ctx, sender.ID, user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly) if err != nil { - log.Error("GetUserSetting: %v", err) - return + return err } if notifyPref == user_model.SettingEmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.SettingEmailNotificationGiteaActionsDisabled { recipients = append(recipients, sender) @@ -160,6 +167,8 @@ func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo } if len(recipients) > 0 { - composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients) + log.Debug("MailActionsTrigger: Initiate email composition") + return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients) } + return nil } diff --git a/services/mailer/notify.go b/services/mailer/notify.go index c008685e13..a7df0052bc 100644 --- a/services/mailer/notify.go +++ b/services/mailer/notify.go @@ -208,8 +208,7 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner * } func (m *mailNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { - if !run.Status.IsDone() { - return + if err := MailActionsTrigger(ctx, sender, repo, run); err != nil { + log.Error("MailActionsTrigger: %v", err) } - MailActionsTrigger(ctx, sender, repo, run) } diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index f1abac8cfa..d54a604655 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -12,6 +12,7 @@ import ( "path" "strings" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/repo" @@ -24,7 +25,9 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/tests" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" @@ -1102,45 +1105,465 @@ type workflowRunWebhook struct { } func Test_WebhookWorkflowRun(t *testing.T) { - webhookData := &workflowRunWebhook{} - provider := newMockWebhookProvider(func(r *http.Request) { - assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_run", "X-GitHub-Event-Type should contain workflow_run") - assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_run", "X-Gitea-Event-Type should contain workflow_run") - assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_run", "X-Gogs-Event-Type should contain workflow_run") - content, _ := io.ReadAll(r.Body) - var payload api.WorkflowRunPayload - err := json.Unmarshal(content, &payload) - assert.NoError(t, err) - webhookData.payloads = append(webhookData.payloads, payload) - webhookData.triggeredEvent = "workflow_run" - }, http.StatusOK) - defer provider.Close() - webhookData.URL = provider.URL() - - tests := []struct { + testCases := []struct { name string - callback func(t *testing.T, webhookData *workflowRunWebhook) + testFunc func(t *testing.T, webhookData *workflowRunWebhook) }{ { name: "WorkflowRun", - callback: testWebhookWorkflowRun, + testFunc: testWebhookWorkflowRun, }, { name: "WorkflowRunDepthLimit", - callback: testWebhookWorkflowRunDepthLimit, + testFunc: testWebhookWorkflowRunDepthLimit, + }, + { + name: "WorkflowRunEvents", + testFunc: testWorkflowRunEvents, + }, + { + name: "WorkflowRunEventsOnRerun", + testFunc: testWorkflowRunEventsOnRerun, + }, + { + name: "WorkflowRunEventsOnCancellingAbandonedRunAllJobsAbandoned", + testFunc: func(t *testing.T, webhookData *workflowRunWebhook) { + testWorkflowRunEventsOnCancellingAbandonedRun(t, webhookData, true) + }, + }, + { + name: "WorkflowRunEventsOnCancellingAbandonedRunPartiallyAbandoned", + testFunc: func(t *testing.T, webhookData *workflowRunWebhook) { + testWorkflowRunEventsOnCancellingAbandonedRun(t, webhookData, false) + }, }, } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - webhookData.payloads = nil - webhookData.triggeredEvent = "" + for _, obj := range testCases { + t.Run(obj.name, func(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - test.callback(t, webhookData) + webhookData := &workflowRunWebhook{} + provider := newMockWebhookProvider(func(r *http.Request) { + assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_run", "X-GitHub-Event-Type should contain workflow_run") + assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_run", "X-Gitea-Event-Type should contain workflow_run") + assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_run", "X-Gogs-Event-Type should contain workflow_run") + content, _ := io.ReadAll(r.Body) + var payload api.WorkflowRunPayload + err := json.Unmarshal(content, &payload) + assert.NoError(t, err) + webhookData.payloads = append(webhookData.payloads, payload) + webhookData.triggeredEvent = "workflow_run" + }, http.StatusOK) + defer provider.Close() + webhookData.URL = provider.URL() + webhookData.payloads = nil + webhookData.triggeredEvent = "" + obj.testFunc(t, webhookData) }) }) } } +func testWorkflowRunEvents(t *testing.T, webhookData *workflowRunWebhook) { + // 1. create a new webhook with special webhook for repo1 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run") + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) + + gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1) + assert.NoError(t, err) + + // 2.2 trigger the webhooks + + // add workflow file to the repo + // init the workflow + wfTreePath := ".gitea/workflows/push.yml" + wfFileContent := `on: + push: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test2: + needs: [test] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test3: + needs: [test, test2] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test4: + needs: [test, test2, test3] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test5: + needs: [test, test2, test4] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test6: + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] + needs: [test, test2, test3] + runs-on: ${{ matrix.os }} + steps: + - run: exit 0 + + test7: + needs: test6 + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test8: + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test9: + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15] + runs-on: ${{ matrix.os }} + steps: + - run: exit 0 + + test10: + runs-on: ubuntu-latest + steps: + - run: exit 0` + opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) + createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) + + commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) + assert.NoError(t, err) + + // 3. validate the webhook is triggered + assert.Equal(t, "workflow_run", webhookData.triggeredEvent) + assert.Len(t, webhookData.payloads, 1) + assert.Equal(t, "requested", webhookData.payloads[0].Action) + assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status) + assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) + assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name) + assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName) + + // Call cancel ui api + // Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. + cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.RunNumber) + req := NewRequestWithValues(t, "POST", cancelURL, map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.Len(t, webhookData.payloads, 2) + + // 4. Validate the second webhook payload + assert.Equal(t, "workflow_run", webhookData.triggeredEvent) + assert.Equal(t, "completed", webhookData.payloads[1].Action) + assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event) + assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) + assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) + assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name) + assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName) +} + +func testWorkflowRunEventsOnRerun(t *testing.T, webhookData *workflowRunWebhook) { + // 1. create a new webhook with special webhook for repo1 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + runners := make([]*mockRunner, 2) + for i := range runners { + runners[i] = newMockRunner() + runners[i].registerAsRepoRunner(t, "user2", "repo1", fmt.Sprintf("mock-runner-%d", i), []string{"ubuntu-latest"}, false) + } + + testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run") + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) + + gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1) + assert.NoError(t, err) + + // 2.2 trigger the webhooks + + // add workflow file to the repo + // init the workflow + wfTreePath := ".gitea/workflows/push.yml" + wfFileContent := `on: + push: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test2: + needs: [test] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test3: + needs: [test, test2] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test4: + needs: [test, test2, test3] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test5: + needs: [test, test2, test4] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test6: + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] + needs: [test, test2, test3] + runs-on: ${{ matrix.os }} + steps: + - run: exit 0 + + test7: + needs: test6 + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test8: + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test9: + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15] + runs-on: ${{ matrix.os }} + steps: + - run: exit 0 + + test10: + runs-on: ubuntu-latest + steps: + - run: exit 0` + opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) + createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) + + commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) + assert.NoError(t, err) + + // 3. validate the webhook is triggered + assert.Equal(t, "workflow_run", webhookData.triggeredEvent) + assert.Len(t, webhookData.payloads, 1) + assert.Equal(t, "requested", webhookData.payloads[0].Action) + assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status) + assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) + assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name) + assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName) + + for _, runner := range runners { + task := runner.fetchTask(t) + runner.execTask(t, task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + } + + // Call cancel ui api + // Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. + cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.RunNumber) + req := NewRequestWithValues(t, "POST", cancelURL, map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.Len(t, webhookData.payloads, 2) + + // 4. Validate the second webhook payload + assert.Equal(t, "workflow_run", webhookData.triggeredEvent) + assert.Equal(t, "completed", webhookData.payloads[1].Action) + assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event) + assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) + assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) + assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name) + assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName) + + // Call rerun ui api + // Only a web UI API exists for cancelling workflow runs, so use the UI endpoint. + rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", webhookData.payloads[0].WorkflowRun.RunNumber) + req = NewRequestWithValues(t, "POST", rerunURL, map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.Len(t, webhookData.payloads, 3) +} + +func testWorkflowRunEventsOnCancellingAbandonedRun(t *testing.T, webhookData *workflowRunWebhook, allJobsAbandoned bool) { + defer test.MockVariableValue(&setting.Actions.AbandonedJobTimeout, 0*time.Nanosecond)() + + // 1. create a new webhook with special webhook for repo1 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + repoName := "test-workflow-run-cancelling-abandoned-run" + testRepo := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: createActionsTestRepo(t, token, repoName, false).ID}) + + runners := make([]*mockRunner, 2) + for i := range runners { + runners[i] = newMockRunner() + runners[i].registerAsRepoRunner(t, "user2", repoName, + fmt.Sprintf("mock-runner-%d", i), []string{"ubuntu-latest"}, false) + } + + testAPICreateWebhookForRepo(t, session, "user2", repoName, webhookData.URL, "workflow_run") + + ctx := t.Context() + gitRepo, err := gitrepo.OpenRepository(ctx, testRepo) + assert.NoError(t, err) + + // 2.2 trigger the webhooks + + // add workflow file to the repo + // init the workflow + wfilename := "push.yml" + wfTreePath := ".gitea/workflows/" + wfilename + wfFileContent := `on: + push: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test2: + needs: [test] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test3: + needs: [test, test2] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test4: + needs: [test, test2, test3] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test5: + needs: [test, test2, test4] + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test6: + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] + needs: [test, test2, test3] + runs-on: ${{ matrix.os }} + steps: + - run: exit 0 + + test7: + needs: test6 + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test8: + runs-on: ubuntu-latest + steps: + - run: exit 0 + + test9: + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15] + runs-on: ${{ matrix.os }} + steps: + - run: exit 0 + + test10: + runs-on: ubuntu-latest + steps: + - run: exit 0` + + opts := getWorkflowCreateFileOptions(user2, testRepo.DefaultBranch, "create "+wfTreePath, wfFileContent) + createWorkflowFile(t, token, "user2", repoName, wfTreePath, opts) + + commitID, err := gitRepo.GetBranchCommitID(testRepo.DefaultBranch) + assert.NoError(t, err) + + // 3. validate the webhook is triggered + assert.Equal(t, "workflow_run", webhookData.triggeredEvent) + assert.Len(t, webhookData.payloads, 1) + assert.Equal(t, "requested", webhookData.payloads[0].Action) + assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status) + assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha) + assert.Equal(t, repoName, webhookData.payloads[0].Repo.Name) + assert.Equal(t, "user2/"+repoName, webhookData.payloads[0].Repo.FullName) + + if !allJobsAbandoned { + for _, runner := range runners { + task := runner.fetchTask(t) + runner.execTask(t, task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + } + } + + // Add this sleep to ensure the func can find the tasks by timestamp. + time.Sleep(time.Second) + + err = actions.CancelAbandonedJobs(ctx) + assert.NoError(t, err) + assert.Len(t, webhookData.payloads, 2) + assert.Equal(t, "completed", webhookData.payloads[1].Action) + assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status) + assert.Equal(t, testRepo.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch) + assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha) + assert.Equal(t, repoName, webhookData.payloads[1].Repo.Name) + assert.Equal(t, "user2/"+repoName, webhookData.payloads[1].Repo.FullName) +} + func testWebhookWorkflowRun(t *testing.T, webhookData *workflowRunWebhook) { // 1. create a new webhook with special webhook for repo1 user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})