// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions import ( "testing" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" "gitea.dev/modules/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestApproveRuns(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) var emittedRunIDs []int64 defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error { emittedRunIDs = append(emittedRunIDs, runID) return nil })() insertRun := func(index int64, status actions_model.Status, needApproval bool, approvedBy int64) *actions_model.ActionRun { run := &actions_model.ActionRun{ Title: "approve-run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index, TriggerUserID: doer.ID, Ref: "refs/heads/main", CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push", Status: status, NeedApproval: needApproval, ApprovedBy: approvedBy, } require.NoError(t, db.Insert(t.Context(), run)) return run } insertJob := func(run *actions_model.ActionRun, status actions_model.Status, needs ...string) *actions_model.ActionRunJob { job := &actions_model.ActionRunJob{ RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA, Name: "job1", Attempt: 1, JobID: "job1", Status: status, RunsOn: []string{"ubuntu-latest"}, Needs: needs, WorkflowPayload: minimalWorkflowPayload("job1"), } require.NoError(t, db.Insert(t.Context(), job)) return job } t.Run("approve unblocks a job with no dependencies", func(t *testing.T) { run := insertRun(1001, actions_model.StatusBlocked, true, 0) job := insertJob(run, actions_model.StatusBlocked) approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) require.NoError(t, err) require.Len(t, approved, 1) assert.False(t, approved[0].NeedApproval) assert.Equal(t, doer.ID, approved[0].ApprovedBy) assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status) }) t.Run("approve skips a job whose if is false", func(t *testing.T) { defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })() run := insertRun(1009, actions_model.StatusBlocked, true, 0) job := insertJob(run, actions_model.StatusBlocked) job.WorkflowPayload = []byte("jobs:\n job1:\n if: false\n") _, err := actions_model.UpdateRunJob(t.Context(), job, nil, "workflow_payload") require.NoError(t, err) _, err = ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) require.NoError(t, err) assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status) }) t.Run("a job with unmet dependencies stays blocked", func(t *testing.T) { run := insertRun(1002, actions_model.StatusBlocked, true, 0) job := insertJob(run, actions_model.StatusBlocked, "some-other-job") approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) require.NoError(t, err) require.Len(t, approved, 1) assert.False(t, approved[0].NeedApproval) assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status) }) t.Run("approval never revives cancelled runs or jobs and re-emits approved runs", func(t *testing.T) { cancelledRun := insertRun(1006, actions_model.StatusCancelled, true, 0) cancelledRunJob := insertJob(cancelledRun, actions_model.StatusCancelled) blockedRun := insertRun(1007, actions_model.StatusBlocked, true, 0) cancelledJob := insertJob(blockedRun, actions_model.StatusCancelled) emittedRunIDs = nil approved, err := ApproveRuns(t.Context(), repo, doer, []int64{cancelledRun.ID, blockedRun.ID}) require.NoError(t, err) require.Len(t, approved, 2) assert.True(t, approved[0].NeedApproval) assert.Zero(t, approved[0].ApprovedBy) assert.False(t, approved[1].NeedApproval) assert.Equal(t, []int64{blockedRun.ID}, emittedRunIDs) assert.Equal(t, actions_model.StatusCancelled, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: cancelledRunJob.ID}).Status) assert.Equal(t, actions_model.StatusCancelled, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: cancelledJob.ID}).Status) }) t.Run("approval starts one of two blocked jobs sharing a concurrency group", func(t *testing.T) { run := insertRun(1008, actions_model.StatusBlocked, true, 0) jobs := []*actions_model.ActionRunJob{insertJob(run, actions_model.StatusBlocked), insertJob(run, actions_model.StatusBlocked)} for _, job := range jobs { job.RawConcurrency, job.ConcurrencyGroup, job.IsConcurrencyEvaluated = "group: siblings", "siblings", true _, err := actions_model.UpdateRunJob(t.Context(), job, nil, "raw_concurrency", "concurrency_group", "is_concurrency_evaluated") require.NoError(t, err) } _, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) require.NoError(t, err) assert.ElementsMatch(t, []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusCancelled}, []actions_model.Status{ unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobs[0].ID}).Status, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobs[1].ID}).Status, }) }) t.Run("workflow concurrency cancels peers on approval, not on insertion", func(t *testing.T) { previousRun := &actions_model.ActionRun{RepoID: 4, OwnerID: 1, Index: 9001, TriggerUserID: 1, TriggerEvent: "push", EventPayload: "{}", Status: actions_model.StatusWaiting} require.NoError(t, db.Insert(t.Context(), previousRun)) previousAttempt := &actions_model.ActionRunAttempt{RepoID: 4, RunID: previousRun.ID, Attempt: 1, Status: actions_model.StatusWaiting, ConcurrencyGroup: "shared", ConcurrencyCancel: true} require.NoError(t, db.Insert(t.Context(), previousAttempt)) previousJob := &actions_model.ActionRunJob{RunID: previousRun.ID, RunAttemptID: previousAttempt.ID, RepoID: 4, OwnerID: 1, JobID: "deploy", AttemptJobID: 1, Status: actions_model.StatusWaiting} require.NoError(t, db.Insert(t.Context(), previousJob)) content := []byte(`on: pull_request concurrency: group: shared cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest concurrency: group: shared cancel-in-progress: true steps: - run: echo hi `) run := &actions_model.ActionRun{ RepoID: 4, OwnerID: 1, WorkflowID: "test.yaml", TriggerUserID: 2, Ref: "refs/pull/1/head", CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "pull_request", TriggerEvent: "pull_request", EventPayload: "{}", NeedApproval: true, WorkflowRepoID: 4, WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", } require.NoError(t, PrepareRunAndInsert(t.Context(), content, run, nil)) assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: previousJob.ID}).Status) _, err := ApproveRuns(t.Context(), unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}), doer, []int64{run.ID}) require.NoError(t, err) assert.Equal(t, actions_model.StatusCancelled, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: previousJob.ID}).Status) jobs := runJobs(t, run.ID, run.LatestAttemptID) require.Len(t, jobs, 1) assert.Equal(t, actions_model.StatusWaiting, jobs[0].Status) }) t.Run("re-approving an approved run is a no-op", func(t *testing.T) { run := insertRun(1005, actions_model.StatusRunning, false, 4) job := insertJob(run, actions_model.StatusRunning) approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) require.NoError(t, err) require.Len(t, approved, 1) assert.EqualValues(t, 4, approved[0].ApprovedBy, "approver must not be overwritten") assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting") }) t.Run("approving several runs returns them in the requested order", func(t *testing.T) { run1 := insertRun(1003, actions_model.StatusBlocked, true, 0) insertJob(run1, actions_model.StatusBlocked) run2 := insertRun(1004, actions_model.StatusBlocked, true, 0) insertJob(run2, actions_model.StatusBlocked) approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run2.ID, run1.ID}) require.NoError(t, err) require.Len(t, approved, 2) assert.Equal(t, run2.ID, approved[0].ID) assert.Equal(t, run1.ID, approved[1].ID) assert.False(t, approved[0].NeedApproval) assert.False(t, approved[1].NeedApproval) }) }