Skip to content

Commit 6bcdd7e

Browse files
committed
fix more workflow_run completion events
* Not every abandoned job per run sends an completion event * Rerun multiple Jobs only send requested event once
1 parent 0506162 commit 6bcdd7e

File tree

3 files changed

+317
-2
lines changed

3 files changed

+317
-2
lines changed

routers/web/repo/actions/view.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,12 @@ func Rerun(ctx *context_module.Context) {
429429
ctx.ServerError("UpdateRun", err)
430430
return
431431
}
432+
433+
if err := run.LoadAttributes(ctx); err != nil {
434+
ctx.ServerError("run.LoadAttributes", err)
435+
return
436+
}
437+
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
432438
}
433439

434440
job, jobs := getRunJobs(ctx, runIndex, jobIndex)
@@ -485,7 +491,6 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou
485491
}
486492

487493
actions_service.CreateCommitStatus(ctx, job)
488-
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
489494
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
490495

491496
return nil

services/actions/clear_tasks.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ func CancelAbandonedJobs(ctx context.Context) error {
111111
}
112112

113113
now := timeutil.TimeStampNow()
114+
115+
// Collect one job per run to send workflow run status update
116+
updatedRuns := map[int64]*actions_model.ActionRunJob{}
117+
114118
for _, job := range jobs {
115119
job.Status = actions_model.StatusCancelled
116120
job.Stopped = now
@@ -125,10 +129,24 @@ func CancelAbandonedJobs(ctx context.Context) error {
125129
}
126130
CreateCommitStatus(ctx, job)
127131
if updated {
128-
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
132+
updatedRuns[job.RunID] = job
129133
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
130134
}
131135
}
132136

137+
for _, job := range updatedRuns {
138+
c, err := db.Count[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
139+
RunID: job.RunID,
140+
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning},
141+
})
142+
if err != nil {
143+
log.Error("Count waiting jobs for run %d: %v", job.RunID, err)
144+
continue
145+
}
146+
if c == 0 {
147+
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
148+
}
149+
}
150+
133151
return nil
134152
}

tests/integration/repo_webhook_test.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path"
1313
"strings"
1414
"testing"
15+
"time"
1516

1617
auth_model "code.gitea.io/gitea/models/auth"
1718
"code.gitea.io/gitea/models/repo"
@@ -24,7 +25,9 @@ import (
2425
"code.gitea.io/gitea/modules/json"
2526
"code.gitea.io/gitea/modules/setting"
2627
api "code.gitea.io/gitea/modules/structs"
28+
"code.gitea.io/gitea/modules/test"
2729
webhook_module "code.gitea.io/gitea/modules/webhook"
30+
"code.gitea.io/gitea/services/actions"
2831
"code.gitea.io/gitea/tests"
2932

3033
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
@@ -1133,6 +1136,22 @@ func Test_WebhookWorkflowRun(t *testing.T) {
11331136
name: "WorkflowRunDuplicateEvents",
11341137
callback: testWorkflowRunDuplicateEvents,
11351138
},
1139+
{
1140+
name: "WorkflowRunEventDuplicateEventsRerun",
1141+
callback: testWorkflowRunDuplicateEventsRerun,
1142+
},
1143+
{
1144+
name: "WorkflowRunDuplicateEventsCancelAbandoned",
1145+
callback: func(t *testing.T, webhookData *workflowRunWebhook) {
1146+
testWorkflowRunDuplicateEventsCancelAbandoned(t, webhookData, true)
1147+
},
1148+
},
1149+
{
1150+
name: "WorkflowRunDuplicateEventsCancelAbandoned",
1151+
callback: func(t *testing.T, webhookData *workflowRunWebhook) {
1152+
testWorkflowRunDuplicateEventsCancelAbandoned(t, webhookData, false)
1153+
},
1154+
},
11361155
}
11371156
for _, test := range tests {
11381157
t.Run(test.name, func(t *testing.T) {
@@ -1266,6 +1285,279 @@ jobs:
12661285
assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName)
12671286
}
12681287

1288+
func testWorkflowRunDuplicateEventsRerun(t *testing.T, webhookData *workflowRunWebhook) {
1289+
// 1. create a new webhook with special webhook for repo1
1290+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
1291+
session := loginUser(t, "user2")
1292+
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
1293+
1294+
runners := make([]*mockRunner, 2)
1295+
for i := 0; i < len(runners); i++ {
1296+
runners[i] = newMockRunner()
1297+
runners[i].registerAsRepoRunner(t, "user2", "repo1", fmt.Sprintf("mock-runner-%d", i), []string{"ubuntu-latest"}, false)
1298+
}
1299+
1300+
testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run")
1301+
1302+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
1303+
1304+
gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1)
1305+
assert.NoError(t, err)
1306+
1307+
// 2.2 trigger the webhooks
1308+
1309+
// add workflow file to the repo
1310+
// init the workflow
1311+
wfTreePath := ".gitea/workflows/push.yml"
1312+
wfFileContent := `on:
1313+
push:
1314+
workflow_dispatch:
1315+
1316+
jobs:
1317+
test:
1318+
runs-on: ubuntu-latest
1319+
steps:
1320+
- run: exit 0
1321+
1322+
test2:
1323+
needs: [test]
1324+
runs-on: ubuntu-latest
1325+
steps:
1326+
- run: exit 0
1327+
1328+
test3:
1329+
needs: [test, test2]
1330+
runs-on: ubuntu-latest
1331+
steps:
1332+
- run: exit 0
1333+
1334+
test4:
1335+
needs: [test, test2, test3]
1336+
runs-on: ubuntu-latest
1337+
steps:
1338+
- run: exit 0
1339+
1340+
test5:
1341+
needs: [test, test2, test4]
1342+
runs-on: ubuntu-latest
1343+
steps:
1344+
- run: exit 0
1345+
1346+
test6:
1347+
strategy:
1348+
matrix:
1349+
os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04]
1350+
needs: [test, test2, test3]
1351+
runs-on: ${{ matrix.os }}
1352+
steps:
1353+
- run: exit 0
1354+
1355+
test7:
1356+
needs: test6
1357+
runs-on: ubuntu-latest
1358+
steps:
1359+
- run: exit 0
1360+
1361+
test8:
1362+
runs-on: ubuntu-latest
1363+
steps:
1364+
- run: exit 0
1365+
1366+
test9:
1367+
strategy:
1368+
matrix:
1369+
os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15]
1370+
runs-on: ${{ matrix.os }}
1371+
steps:
1372+
- run: exit 0
1373+
1374+
test10:
1375+
runs-on: ubuntu-latest
1376+
steps:
1377+
- run: exit 0`
1378+
opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent)
1379+
createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts)
1380+
1381+
commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch)
1382+
assert.NoError(t, err)
1383+
1384+
// 3. validate the webhook is triggered
1385+
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
1386+
assert.Len(t, webhookData.payloads, 1)
1387+
assert.Equal(t, "requested", webhookData.payloads[0].Action)
1388+
assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status)
1389+
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch)
1390+
assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha)
1391+
assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name)
1392+
assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName)
1393+
1394+
tasks := make([]*runnerv1.Task, len(runners))
1395+
for i := 0; i < len(runners); i++ {
1396+
tasks[i] = runners[i].fetchTask(t)
1397+
runners[i].execTask(t, tasks[i], &mockTaskOutcome{
1398+
result: runnerv1.Result_RESULT_SUCCESS,
1399+
})
1400+
}
1401+
1402+
// Call cancel ui api
1403+
// Only a web UI API exists for cancelling workflow runs, so use the UI endpoint.
1404+
cancelURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/cancel", webhookData.payloads[0].WorkflowRun.RunNumber)
1405+
req := NewRequestWithValues(t, "POST", cancelURL, map[string]string{
1406+
"_csrf": GetUserCSRFToken(t, session),
1407+
})
1408+
session.MakeRequest(t, req, http.StatusOK)
1409+
1410+
assert.Len(t, webhookData.payloads, 2)
1411+
1412+
// 4. Validate the second webhook payload
1413+
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
1414+
assert.Equal(t, "completed", webhookData.payloads[1].Action)
1415+
assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event)
1416+
assert.Equal(t, "completed", webhookData.payloads[1].WorkflowRun.Status)
1417+
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[1].WorkflowRun.HeadBranch)
1418+
assert.Equal(t, commitID, webhookData.payloads[1].WorkflowRun.HeadSha)
1419+
assert.Equal(t, "repo1", webhookData.payloads[1].Repo.Name)
1420+
assert.Equal(t, "user2/repo1", webhookData.payloads[1].Repo.FullName)
1421+
1422+
// Call rerun ui api
1423+
// Only a web UI API exists for cancelling workflow runs, so use the UI endpoint.
1424+
rerunURL := fmt.Sprintf("/user2/repo1/actions/runs/%d/rerun", webhookData.payloads[0].WorkflowRun.RunNumber)
1425+
req = NewRequestWithValues(t, "POST", rerunURL, map[string]string{
1426+
"_csrf": GetUserCSRFToken(t, session),
1427+
})
1428+
session.MakeRequest(t, req, http.StatusOK)
1429+
1430+
assert.Len(t, webhookData.payloads, 3)
1431+
}
1432+
1433+
func testWorkflowRunDuplicateEventsCancelAbandoned(t *testing.T, webhookData *workflowRunWebhook, partiallyAbandoned bool) {
1434+
// 1. create a new webhook with special webhook for repo1
1435+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
1436+
session := loginUser(t, "user2")
1437+
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
1438+
1439+
runners := make([]*mockRunner, 2)
1440+
for i := 0; i < len(runners); i++ {
1441+
runners[i] = newMockRunner()
1442+
runners[i].registerAsRepoRunner(t, "user2", "repo1", fmt.Sprintf("mock-runner-%d", i), []string{"ubuntu-latest"}, false)
1443+
}
1444+
1445+
testAPICreateWebhookForRepo(t, session, "user2", "repo1", webhookData.URL, "workflow_run")
1446+
1447+
repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
1448+
1449+
gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1)
1450+
assert.NoError(t, err)
1451+
1452+
// 2.2 trigger the webhooks
1453+
1454+
// add workflow file to the repo
1455+
// init the workflow
1456+
wfTreePath := ".gitea/workflows/push.yml"
1457+
wfFileContent := `on:
1458+
push:
1459+
workflow_dispatch:
1460+
1461+
jobs:
1462+
test:
1463+
runs-on: ubuntu-latest
1464+
steps:
1465+
- run: exit 0
1466+
1467+
test2:
1468+
needs: [test]
1469+
runs-on: ubuntu-latest
1470+
steps:
1471+
- run: exit 0
1472+
1473+
test3:
1474+
needs: [test, test2]
1475+
runs-on: ubuntu-latest
1476+
steps:
1477+
- run: exit 0
1478+
1479+
test4:
1480+
needs: [test, test2, test3]
1481+
runs-on: ubuntu-latest
1482+
steps:
1483+
- run: exit 0
1484+
1485+
test5:
1486+
needs: [test, test2, test4]
1487+
runs-on: ubuntu-latest
1488+
steps:
1489+
- run: exit 0
1490+
1491+
test6:
1492+
strategy:
1493+
matrix:
1494+
os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04]
1495+
needs: [test, test2, test3]
1496+
runs-on: ${{ matrix.os }}
1497+
steps:
1498+
- run: exit 0
1499+
1500+
test7:
1501+
needs: test6
1502+
runs-on: ubuntu-latest
1503+
steps:
1504+
- run: exit 0
1505+
1506+
test8:
1507+
runs-on: ubuntu-latest
1508+
steps:
1509+
- run: exit 0
1510+
1511+
test9:
1512+
strategy:
1513+
matrix:
1514+
os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04, ubuntu-25.04, windows-2022, windows-2025, macos-13, macos-14, macos-15]
1515+
runs-on: ${{ matrix.os }}
1516+
steps:
1517+
- run: exit 0
1518+
1519+
test10:
1520+
runs-on: ubuntu-latest
1521+
steps:
1522+
- run: exit 0`
1523+
opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent)
1524+
createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts)
1525+
1526+
commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch)
1527+
assert.NoError(t, err)
1528+
1529+
// 3. validate the webhook is triggered
1530+
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
1531+
assert.Len(t, webhookData.payloads, 1)
1532+
assert.Equal(t, "requested", webhookData.payloads[0].Action)
1533+
assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status)
1534+
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch)
1535+
assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha)
1536+
assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name)
1537+
assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName)
1538+
1539+
tasks := make([]*runnerv1.Task, len(runners))
1540+
for i := 0; i < len(runners); i++ {
1541+
tasks[i] = runners[i].fetchTask(t)
1542+
if !partiallyAbandoned {
1543+
runners[i].execTask(t, tasks[i], &mockTaskOutcome{
1544+
result: runnerv1.Result_RESULT_SUCCESS,
1545+
})
1546+
}
1547+
}
1548+
1549+
defer test.MockVariableValue(&setting.Actions.AbandonedJobTimeout, (time.Duration)(0))()
1550+
1551+
err = actions.CancelAbandonedJobs(t.Context())
1552+
assert.NoError(t, err)
1553+
1554+
if partiallyAbandoned {
1555+
assert.Len(t, webhookData.payloads, 1)
1556+
} else {
1557+
assert.Len(t, webhookData.payloads, 2)
1558+
}
1559+
}
1560+
12691561
func testWebhookWorkflowRun(t *testing.T, webhookData *workflowRunWebhook) {
12701562
// 1. create a new webhook with special webhook for repo1
12711563
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})

0 commit comments

Comments
 (0)