2023-01-31 09:45:19 +08:00
|
|
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
|
|
|
|
package actions
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
actions_model "code.gitea.io/gitea/models/actions"
|
2026-04-15 01:58:31 +08:00
|
|
|
"code.gitea.io/gitea/models/db"
|
|
|
|
|
"code.gitea.io/gitea/models/unittest"
|
2023-01-31 09:45:19 +08:00
|
|
|
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func Test_jobStatusResolver_Resolve(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
jobs actions_model.ActionJobList
|
|
|
|
|
want map[int64]actions_model.Status
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
name: "no blocked",
|
|
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
|
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
|
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "single blocked",
|
|
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
|
|
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
|
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{
|
|
|
|
|
2: actions_model.StatusWaiting,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "multiple blocked",
|
|
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
|
|
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
|
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{
|
|
|
|
|
2: actions_model.StatusWaiting,
|
|
|
|
|
3: actions_model.StatusWaiting,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "chain blocked",
|
|
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}},
|
|
|
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
|
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{
|
|
|
|
|
2: actions_model.StatusSkipped,
|
|
|
|
|
3: actions_model.StatusSkipped,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "loop need",
|
|
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}},
|
|
|
|
|
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
|
|
|
|
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{},
|
|
|
|
|
},
|
2024-02-28 18:54:44 +08:00
|
|
|
{
|
2024-05-27 14:15:34 +08:00
|
|
|
name: "`if` is not empty and all jobs in `needs` completed successfully",
|
2024-02-28 18:54:44 +08:00
|
|
|
jobs: actions_model.ActionJobList{
|
2024-05-27 14:15:34 +08:00
|
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
2024-02-28 18:54:44 +08:00
|
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
|
|
|
`
|
|
|
|
|
name: test
|
|
|
|
|
on: push
|
|
|
|
|
jobs:
|
|
|
|
|
job2:
|
|
|
|
|
runs-on: ubuntu-latest
|
|
|
|
|
needs: job1
|
2024-05-27 14:15:34 +08:00
|
|
|
if: ${{ always() && needs.job1.result == 'success' }}
|
2024-02-28 18:54:44 +08:00
|
|
|
steps:
|
2024-05-27 14:15:34 +08:00
|
|
|
- run: echo "will be checked by act_runner"
|
2024-02-28 18:54:44 +08:00
|
|
|
`)},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
|
|
|
|
},
|
|
|
|
|
{
|
2024-05-27 14:15:34 +08:00
|
|
|
name: "`if` is not empty and not all jobs in `needs` completed successfully",
|
2024-02-28 18:54:44 +08:00
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
|
|
|
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
|
|
|
`
|
|
|
|
|
name: test
|
|
|
|
|
on: push
|
|
|
|
|
jobs:
|
|
|
|
|
job2:
|
|
|
|
|
runs-on: ubuntu-latest
|
|
|
|
|
needs: job1
|
2024-05-27 14:15:34 +08:00
|
|
|
if: ${{ always() && needs.job1.result == 'failure' }}
|
2024-02-28 18:54:44 +08:00
|
|
|
steps:
|
2024-05-27 14:15:34 +08:00
|
|
|
- run: echo "will be checked by act_runner"
|
2024-02-28 18:54:44 +08:00
|
|
|
`)},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
|
|
|
|
},
|
|
|
|
|
{
|
2024-05-27 14:15:34 +08:00
|
|
|
name: "`if` is empty and not all jobs in `needs` completed successfully",
|
2024-02-28 18:54:44 +08:00
|
|
|
jobs: actions_model.ActionJobList{
|
|
|
|
|
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
|
|
|
|
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
|
|
|
|
`
|
|
|
|
|
name: test
|
|
|
|
|
on: push
|
|
|
|
|
jobs:
|
|
|
|
|
job2:
|
|
|
|
|
runs-on: ubuntu-latest
|
|
|
|
|
needs: job1
|
|
|
|
|
steps:
|
2024-05-27 14:15:34 +08:00
|
|
|
- run: echo "should be skipped"
|
2024-02-28 18:54:44 +08:00
|
|
|
`)},
|
|
|
|
|
},
|
|
|
|
|
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
|
|
|
|
|
},
|
2023-01-31 09:45:19 +08:00
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
2025-10-10 12:58:55 -06:00
|
|
|
r := newJobStatusResolver(tt.jobs, nil)
|
|
|
|
|
assert.Equal(t, tt.want, r.Resolve(t.Context()))
|
2023-01-31 09:45:19 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-15 01:58:31 +08:00
|
|
|
|
|
|
|
|
// Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck verifies that when a run's
|
|
|
|
|
// ConcurrencyGroup has already been checked at the run level, the same group is not
|
|
|
|
|
// re-checked for individual jobs.
|
|
|
|
|
func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
|
|
|
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
|
ctx := t.Context()
|
|
|
|
|
|
|
|
|
|
// Run A: the triggering run with a concurrency group.
|
|
|
|
|
runA := &actions_model.ActionRun{
|
|
|
|
|
RepoID: 4,
|
|
|
|
|
OwnerID: 1,
|
|
|
|
|
TriggerUserID: 1,
|
|
|
|
|
WorkflowID: "test.yml",
|
|
|
|
|
Index: 9901,
|
|
|
|
|
Ref: "refs/heads/main",
|
|
|
|
|
Status: actions_model.StatusRunning,
|
|
|
|
|
ConcurrencyGroup: "test-cg",
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, runA))
|
|
|
|
|
|
|
|
|
|
// A done job for run A with the same ConcurrencyGroup.
|
|
|
|
|
// This triggers the job-level concurrency check in checkRunConcurrency.
|
|
|
|
|
jobADone := &actions_model.ActionRunJob{
|
|
|
|
|
RunID: runA.ID,
|
|
|
|
|
RepoID: 4,
|
|
|
|
|
OwnerID: 1,
|
|
|
|
|
JobID: "job1",
|
|
|
|
|
Name: "job1",
|
|
|
|
|
Status: actions_model.StatusSuccess,
|
|
|
|
|
ConcurrencyGroup: "test-cg",
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, jobADone))
|
|
|
|
|
|
|
|
|
|
// Blocked run B competing for the same concurrency group.
|
|
|
|
|
runB := &actions_model.ActionRun{
|
|
|
|
|
RepoID: 4,
|
|
|
|
|
OwnerID: 1,
|
|
|
|
|
TriggerUserID: 1,
|
|
|
|
|
WorkflowID: "test.yml",
|
|
|
|
|
Index: 9902,
|
|
|
|
|
Ref: "refs/heads/main",
|
|
|
|
|
Status: actions_model.StatusBlocked,
|
|
|
|
|
ConcurrencyGroup: "test-cg",
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, runB))
|
|
|
|
|
|
|
|
|
|
// A blocked job belonging to run B (no job-level concurrency group).
|
|
|
|
|
jobBBlocked := &actions_model.ActionRunJob{
|
|
|
|
|
RunID: runB.ID,
|
|
|
|
|
RepoID: 4,
|
|
|
|
|
OwnerID: 1,
|
|
|
|
|
JobID: "job1",
|
|
|
|
|
Name: "job1",
|
|
|
|
|
Status: actions_model.StatusBlocked,
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, jobBBlocked))
|
|
|
|
|
|
|
|
|
|
jobs, _, err := checkRunConcurrency(ctx, runA)
|
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
|
|
|
|
|
if assert.Len(t, jobs, 1) {
|
|
|
|
|
assert.Equal(t, jobBBlocked.ID, jobs[0].ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-04 13:37:20 -07:00
|
|
|
|
|
|
|
|
// Test_checkJobsOfRun_RunLevelConcurrencyKeepsJobsBlocked verifies that
|
|
|
|
|
// the resolver does not transition a job out of Blocked while another run still holds
|
|
|
|
|
// the workflow-level concurrency group. Regression for #37446.
|
|
|
|
|
func Test_checkJobsOfRun_RunLevelConcurrencyKeepsJobsBlocked(t *testing.T) {
|
|
|
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
|
ctx := t.Context()
|
|
|
|
|
|
|
|
|
|
const group = "test-run-level-concurrency-keeps-blocked"
|
|
|
|
|
|
|
|
|
|
// Holder run: Running run in the concurrency group.
|
|
|
|
|
holderRun := &actions_model.ActionRun{
|
|
|
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
|
|
|
|
|
WorkflowID: "test.yml", Index: 9911, Ref: "refs/heads/main",
|
|
|
|
|
Status: actions_model.StatusRunning,
|
|
|
|
|
ConcurrencyGroup: group,
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, holderRun))
|
|
|
|
|
|
|
|
|
|
// Blocked run: Blocked run in the same group, with one Blocked job that has
|
|
|
|
|
// no needs and no job-level concurrency. Without the run-level guard in
|
|
|
|
|
// checkJobsOfRun, the resolver would transition this job to Waiting.
|
|
|
|
|
blockedRun := &actions_model.ActionRun{
|
|
|
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
|
|
|
|
|
WorkflowID: "test.yml", Index: 9912, Ref: "refs/heads/main",
|
|
|
|
|
Status: actions_model.StatusBlocked,
|
|
|
|
|
ConcurrencyGroup: group,
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, blockedRun))
|
|
|
|
|
blockedJob := &actions_model.ActionRunJob{
|
|
|
|
|
RunID: blockedRun.ID,
|
|
|
|
|
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1",
|
|
|
|
|
Status: actions_model.StatusBlocked,
|
|
|
|
|
WorkflowPayload: []byte(`
|
|
|
|
|
name: test
|
|
|
|
|
on: push
|
|
|
|
|
jobs:
|
|
|
|
|
job1:
|
|
|
|
|
runs-on: ubuntu-latest
|
|
|
|
|
steps:
|
|
|
|
|
- run: echo
|
|
|
|
|
`),
|
|
|
|
|
}
|
|
|
|
|
assert.NoError(t, db.Insert(ctx, blockedJob))
|
|
|
|
|
|
|
|
|
|
_, updated, err := checkJobsOfRun(ctx, blockedRun)
|
|
|
|
|
assert.NoError(t, err)
|
|
|
|
|
assert.Empty(t, updated)
|
|
|
|
|
|
|
|
|
|
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
|
|
|
|
|
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
|
|
|
|
|
}
|