@@ -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+
12691561func 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