diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 8096bfb6b5..94a9f11ed6 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -275,3 +275,13 @@ func (job *ActionRunJob) IsIncompleteRunsOn() (bool, *jobparser.IncompleteNeeds, } return jobWorkflow.IncompleteRunsOn, jobWorkflow.IncompleteRunsOnNeeds, jobWorkflow.IncompleteRunsOnMatrix, nil } + +// Check whether this job is a caller of a reusable workflow -- in other words, the real work done in this job is in +// spawned child jobs, not this job. +func (job *ActionRunJob) IsWorkflowCallOuterJob() (bool, error) { + jobWorkflow, err := job.decodeWorkflowPayload() + if err != nil { + return false, fmt.Errorf("failure decoding workflow payload: %w", err) + } + return jobWorkflow.Metadata.WorkflowCallID != "", nil +} diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go index 9cba48705b..82b055d3fa 100644 --- a/models/actions/run_job_test.go +++ b/models/actions/run_job_test.go @@ -159,3 +159,40 @@ func TestActionRunJob_IsIncompleteRunsOn(t *testing.T) { }) } } + +func TestActionRunJob_IsWorkflowCallOuterJob(t *testing.T) { + tests := []struct { + name string + job ActionRunJob + isWorkflowCallOuterJob bool + errContains string + }{ + { + name: "normal workflow", + job: ActionRunJob{WorkflowPayload: []byte("name: workflow")}, + isWorkflowCallOuterJob: false, + }, + { + name: "workflow_call outer job", + job: ActionRunJob{WorkflowPayload: []byte("name: test\njobs:\n job:\n if: false\n__metadata:\n workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed\n")}, + isWorkflowCallOuterJob: true, + }, + { + name: "unparseable workflow", + job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_runs_on: true")}, + errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isWorkflowCallOuterJob, err := tt.job.IsWorkflowCallOuterJob() + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + } else { + require.NoError(t, err) + assert.Equal(t, tt.isWorkflowCallOuterJob, isWorkflowCallOuterJob) + } + }) + } +} diff --git a/models/actions/task.go b/models/actions/task.go index 1924c816bc..517beee999 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -421,6 +421,44 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask return task, true, nil } +// Placeholder tasks are created when the status/content of an `ActionRunJob` is resolved by Forgejo without dispatch to +// a runner, specifically in the case of a workflow call's outer job. +func CreatePlaceholderTask(ctx context.Context, job *ActionRunJob, outputs map[string]string) (*ActionTask, error) { + actionTask := &ActionTask{ + JobID: job.ID, + Attempt: 1, + Started: timeutil.TimeStampNow(), + Stopped: timeutil.TimeStampNow(), + Status: job.Status, + RepoID: job.RepoID, + OwnerID: job.OwnerID, + CommitSHA: job.CommitSHA, + IsForkPullRequest: job.IsForkPullRequest, + } + // token isn't used on a placeholder task, but generation is needed due to the unique constraint on field TokenHash + actionTask.GenerateToken() + + err := db.WithTx(ctx, func(ctx context.Context) error { + _, err := db.GetEngine(ctx).Insert(actionTask) + if err != nil { + return fmt.Errorf("failure inserting action_task: %w", err) + } + + for key, value := range outputs { + err := InsertTaskOutputIfNotExist(ctx, actionTask.ID, key, value) + if err != nil { + return fmt.Errorf("failure inserting action_task_output %q: %w", key, err) + } + } + + return nil + }) + if err != nil { + return nil, err + } + return actionTask, nil +} + func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error { sess := db.GetEngine(ctx).ID(task.ID) if len(cols) > 0 { diff --git a/models/actions/task_test.go b/models/actions/task_test.go index 73aff17a85..831a8bc3dc 100644 --- a/models/actions/task_test.go +++ b/models/actions/task_test.go @@ -46,3 +46,33 @@ func TestActionTask_GetTaskByJobAttempt(t *testing.T) { _, err = GetTaskByJobAttempt(t.Context(), 192, 100) assert.ErrorContains(t, err, "task with job_id 192 and attempt 100: resource does not exist") } + +func TestActionTask_CreatePlaceholderTask(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 396}) + assert.EqualValues(t, 0, job.TaskID) + + task, err := CreatePlaceholderTask(t.Context(), job, map[string]string{"output1": "value1", "output2": "value2"}) + require.NoError(t, err) + + assert.NotEqualValues(t, 0, task.ID) + assert.Equal(t, job.ID, task.JobID) + assert.EqualValues(t, 1, task.Attempt) + assert.NotEqualValues(t, 0, task.Started) + assert.NotEqualValues(t, 0, task.Stopped) + assert.Equal(t, job.Status, task.Status) + assert.Equal(t, job.RepoID, task.RepoID) + assert.Equal(t, job.OwnerID, task.OwnerID) + assert.Equal(t, job.CommitSHA, task.CommitSHA) + assert.Equal(t, job.IsForkPullRequest, task.IsForkPullRequest) + + taskOutputs, err := FindTaskOutputByTaskID(t.Context(), task.ID) + require.NoError(t, err) + require.Len(t, taskOutputs, 2) + finalOutputs := map[string]string{} + for _, to := range taskOutputs { + finalOutputs[to.OutputKey] = to.OutputValue + } + assert.Equal(t, map[string]string{"output1": "value1", "output2": "value2"}, finalOutputs) +} diff --git a/services/actions/TestExpandLocalReusableWorkflows/HEAD b/services/actions/TestExpandLocalReusableWorkflows/HEAD new file mode 100644 index 0000000000..b870d82622 --- /dev/null +++ b/services/actions/TestExpandLocalReusableWorkflows/HEAD @@ -0,0 +1 @@ +ref: refs/heads/main diff --git a/services/actions/TestExpandLocalReusableWorkflows/config b/services/actions/TestExpandLocalReusableWorkflows/config new file mode 100644 index 0000000000..07d359d07c --- /dev/null +++ b/services/actions/TestExpandLocalReusableWorkflows/config @@ -0,0 +1,4 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true diff --git a/services/actions/TestExpandLocalReusableWorkflows/description b/services/actions/TestExpandLocalReusableWorkflows/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/services/actions/TestExpandLocalReusableWorkflows/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/services/actions/TestExpandLocalReusableWorkflows/info/exclude b/services/actions/TestExpandLocalReusableWorkflows/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/services/actions/TestExpandLocalReusableWorkflows/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/services/actions/TestExpandLocalReusableWorkflows/objects/38/96edb578e758154443b81c47333839651eee38 b/services/actions/TestExpandLocalReusableWorkflows/objects/38/96edb578e758154443b81c47333839651eee38 new file mode 100644 index 0000000000..8d466d1636 Binary files /dev/null and b/services/actions/TestExpandLocalReusableWorkflows/objects/38/96edb578e758154443b81c47333839651eee38 differ diff --git a/services/actions/TestExpandLocalReusableWorkflows/objects/7a/5539a17ce49e714463576d89b5d2c7c7d50d8f b/services/actions/TestExpandLocalReusableWorkflows/objects/7a/5539a17ce49e714463576d89b5d2c7c7d50d8f new file mode 100644 index 0000000000..0862515191 Binary files /dev/null and b/services/actions/TestExpandLocalReusableWorkflows/objects/7a/5539a17ce49e714463576d89b5d2c7c7d50d8f differ diff --git a/services/actions/TestExpandLocalReusableWorkflows/objects/a2/8b504c5e53e3448d311b4d487dc15e2b565867 b/services/actions/TestExpandLocalReusableWorkflows/objects/a2/8b504c5e53e3448d311b4d487dc15e2b565867 new file mode 100644 index 0000000000..3d33afbd64 Binary files /dev/null and b/services/actions/TestExpandLocalReusableWorkflows/objects/a2/8b504c5e53e3448d311b4d487dc15e2b565867 differ diff --git a/services/actions/TestExpandLocalReusableWorkflows/objects/b8/9db1fb90367bad85b2f2c70395ab0f2e828f95 b/services/actions/TestExpandLocalReusableWorkflows/objects/b8/9db1fb90367bad85b2f2c70395ab0f2e828f95 new file mode 100644 index 0000000000..c176aef9f2 Binary files /dev/null and b/services/actions/TestExpandLocalReusableWorkflows/objects/b8/9db1fb90367bad85b2f2c70395ab0f2e828f95 differ diff --git a/services/actions/TestExpandLocalReusableWorkflows/objects/e3/868ecb4f8b483fc0bdd422561bf0062a7df907 b/services/actions/TestExpandLocalReusableWorkflows/objects/e3/868ecb4f8b483fc0bdd422561bf0062a7df907 new file mode 100644 index 0000000000..129fc65855 Binary files /dev/null and b/services/actions/TestExpandLocalReusableWorkflows/objects/e3/868ecb4f8b483fc0bdd422561bf0062a7df907 differ diff --git a/services/actions/TestExpandLocalReusableWorkflows/refs/heads/main b/services/actions/TestExpandLocalReusableWorkflows/refs/heads/main new file mode 100644 index 0000000000..e753c68818 --- /dev/null +++ b/services/actions/TestExpandLocalReusableWorkflows/refs/heads/main @@ -0,0 +1 @@ +e3868ecb4f8b483fc0bdd422561bf0062a7df907 diff --git a/services/actions/Test_tryHandleWorkflowCallOuterJob/action_run.yml b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_run.yml new file mode 100644 index 0000000000..0cb546b3aa --- /dev/null +++ b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_run.yml @@ -0,0 +1,19 @@ +# Case w/ action_run_job.id = 601 +- + id: 900 + title: "running workflow_dispatch run" + owner_id: 2 + repo_id: 63 + workflow_id: "running.yaml" + index: 4 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 diff --git a/services/actions/Test_tryHandleWorkflowCallOuterJob/action_run_job.yml b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_run_job.yml new file mode 100644 index 0000000000..c0e55fd1a4 --- /dev/null +++ b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_run_job.yml @@ -0,0 +1,50 @@ +# Case 600 -- workflow that is not a workflow call outer job +- + id: 600 + status: 1 # success + started: 1683636528 + workflow_payload: | + "on": + push: + jobs: + produce-artifacts: + name: produce-artifacts + runs-on: docker + steps: + - run: echo "OK!" + +# Case w/ action_run_job.id = 601 -- workflow call outer job exercising every available context in the `outputs`. Note +# that jobparser translates `on.workflow_call.outputs` into the outer job's `job..outputs`, so the available +# contexts should be considered as those in `on.workflow_call.outputs`. +- + id: 601 + run_id: 900 + status: 1 # success + started: 1683636528 + needs: ["outer-job.inner-job"] + workflow_payload: | + "on": + push: + jobs: + outer-job: + name: outer-job + if: false + uses: ./.forgjeo/workflows/reusable.yml + outputs: + from_inner_job: ${{ needs['outer-job.inner-job'].outputs.my_output }} + from_inner_job_result: ${{ needs['outer-job.inner-job'].result }} + from_forgejo_ctx: ${{ forgejo.ref }} + from_input_ctx: ${{ inputs.wc_input }}! + from_vars_repo: ${{ vars.repo_var }} + from_vars_org: ${{ vars.org_var }} + from_vars_global: ${{ vars.global_var }} + __metadata: + workflow_call_inputs: + wc_input: 'hello, world' + workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed +- # inner job of run 601 + id: 602 + run_id: 900 + status: 1 # success + job_id: outer-job.inner-job + task_id: 100 diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 9484f7aed7..55e05aa712 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -67,6 +67,7 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { for _, job := range jobs { if status, ok := updates[job.ID]; ok { job.Status = status + updateColumns := []string{"status"} if status == actions_model.StatusWaiting { ignore, err := tryHandleIncompleteMatrix(ctx, job, jobs) @@ -75,9 +76,16 @@ func checkJobsOfRun(ctx context.Context, runID int64) error { } else if ignore { continue } + } else if status == actions_model.StatusSuccess || status == actions_model.StatusFailure { + // Transition to these states can be triggered by workflow call outer jobs + additionalColumns, err := tryHandleWorkflowCallOuterJob(ctx, job) + if err != nil { + return fmt.Errorf("error in tryHandleWorkflowCallOuterJob: %w", err) + } + updateColumns = append(updateColumns, additionalColumns...) } - if n, err := UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil { + if n, err := UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, updateColumns...); err != nil { return err } else if n != 1 { return fmt.Errorf("no affected for updating blocked job %v", job.ID) @@ -155,23 +163,34 @@ func (r *jobStatusResolver) resolve() map[int64]actions_model.Status { } } if allDone { - if allSucceed { - ret[id] = actions_model.StatusWaiting - } else { - // Check if the job has an "if" condition - hasIf := false - if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload, false); len(wfJobs) == 1 { - _, wfJob := wfJobs[0].Job() - hasIf = len(wfJob.If.Value) > 0 + if isWorkflowCallOuterJob, _ := r.jobMap[id].IsWorkflowCallOuterJob(); isWorkflowCallOuterJob { + // If the dependent job was a workflow call outer job, then options aren't waiting/skipped, but rather + // success/failure. checkJobsOfRun will do additional work in these cases to "finish" the workflow call + // job as well. + if allSucceed { + ret[id] = actions_model.StatusSuccess + } else { + ret[id] = actions_model.StatusFailure } - - if hasIf { - // act_runner will check the "if" condition + } else { + if allSucceed { ret[id] = actions_model.StatusWaiting } else { - // If the "if" condition is empty and not all dependent jobs completed successfully, - // the job should be skipped. - ret[id] = actions_model.StatusSkipped + // Check if the job has an "if" condition + hasIf := false + if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload, false); len(wfJobs) == 1 { + _, wfJob := wfJobs[0].Job() + hasIf = len(wfJob.If.Value) > 0 + } + + if hasIf { + // act_runner will check the "if" condition + ret[id] = actions_model.StatusWaiting + } else { + // If the "if" condition is empty and not all dependent jobs completed successfully, + // the job should be skipped. + ret[id] = actions_model.StatusSkipped + } } } } @@ -355,3 +374,30 @@ func persistentIncompleteRunsOnError(job *actions_model.ActionRunJob, incomplete errorDetails = []any{job.JobID} return errorCode, errorDetails } + +// When a workflow call outer job's dependencies are completed, `tryHandleWorkflowCallOuterJob` will complete the job +// without actually executing it. It will not be dispatched it to a runner. There's no job execution logic, but we need +// to update state of a few things -- particularly workflow outputs. +// +// A slice of additional columns for the caller to update on the passed-in `ActionRunJob` is returned, in addition to an +// error. +func tryHandleWorkflowCallOuterJob(ctx context.Context, job *actions_model.ActionRunJob) ([]string, error) { + isWorkflowCallOuterJob, err := job.IsWorkflowCallOuterJob() + if err != nil { + return nil, fmt.Errorf("failure to identify workflow call outer job: %w", err) + } else if !isWorkflowCallOuterJob { + // Not an expected code path today, but if job status resolution changes in the future we might "try" to handle + // a workflow call outer job while it's not really in that state. No work required. + return nil, nil + } + + // Insert a placeholder task; this will be used in the future to store computed outputs + actionTask, err := actions_model.CreatePlaceholderTask(ctx, job, map[string]string{}) + if err != nil { + return nil, fmt.Errorf("failure to insert placeholder task: %w", err) + } + + // Populate task_id and ask caller to update it in DB: + job.TaskID = actionTask.ID + return []string{"task_id"}, nil +} diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index 0125e924d1..5e1885c709 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -133,6 +133,48 @@ jobs: }, want: map[int64]actions_model.Status{2: actions_model.StatusSkipped}, }, + { + name: "unblocked workflow call outer job with success", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}}, + {ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusSuccess, Needs: []string{}}, + {ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte( + ` +name: test +on: push +jobs: + job2: + if: false + uses: ./.forgejo/workflows/reusable.yml +__metadata: + workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed +`)}, + }, + want: map[int64]actions_model.Status{ + 3: actions_model.StatusSuccess, + }, + }, + { + name: "unblocked workflow call outer job with internal failure", + jobs: actions_model.ActionJobList{ + {ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}}, + {ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusFailure, Needs: []string{}}, + {ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte( + ` +name: test +on: push +jobs: + job2: + if: false + uses: ./.forgejo/workflows/reusable.yml +__metadata: + workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed +`)}, + }, + want: map[int64]actions_model.Status{ + 3: actions_model.StatusFailure, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -382,3 +424,49 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) { }) } } + +func Test_tryHandleWorkflowCallOuterJob(t *testing.T) { + tests := []struct { + name string + runJobID int64 + updateFields []string + outputs map[string]string + }{ + { + name: "not workflow call outer job", + runJobID: 600, + }, + { + name: "outputs for every context", + runJobID: 601, + updateFields: []string{"task_id"}, + outputs: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/Test_tryHandleWorkflowCallOuterJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + outerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: tt.runJobID}) + require.EqualValues(t, 0, outerJob.TaskID) + + updateFields, err := tryHandleWorkflowCallOuterJob(t.Context(), outerJob) + require.NoError(t, err) + assert.Equal(t, tt.updateFields, updateFields) + + if tt.updateFields != nil { + // TaskID expected to be set by tryHandleWorkflowCallOuterJob + require.NotEqualValues(t, 0, outerJob.TaskID) + + taskOutputs, err := actions_model.FindTaskOutputByTaskID(t.Context(), outerJob.TaskID) + require.NoError(t, err) + outputMap := map[string]string{} + for _, to := range taskOutputs { + outputMap[to.OutputKey] = to.OutputValue + } + assert.Equal(t, tt.outputs, outputMap) + } + }) + } +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index cb27914ae0..3e7c1f609c 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -420,6 +420,8 @@ func handleWorkflows( // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs. jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.SupportIncompleteRunsOn(), + jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflows(commit)), + jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)), ) if err != nil { log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err) diff --git a/services/actions/notifier_helper_test.go b/services/actions/notifier_helper_test.go index 343106454d..1b2d26671d 100644 --- a/services/actions/notifier_helper_test.go +++ b/services/actions/notifier_helper_test.go @@ -4,6 +4,7 @@ package actions import ( + "context" "errors" "slices" "testing" @@ -17,9 +18,11 @@ import ( actions_module "forgejo.org/modules/actions" "forgejo.org/modules/git" api "forgejo.org/modules/structs" + "forgejo.org/modules/test" webhook_module "forgejo.org/modules/webhook" "code.forgejo.org/forgejo/runner/v12/act/jobparser" + "code.forgejo.org/forgejo/runner/v12/act/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -353,3 +356,58 @@ func TestActionsNotifier_WorkflowDetection(t *testing.T) { assert.Equal(t, ".forgejo/workflows", runs[0].WorkflowDirectory) assert.Equal(t, "test.yml", runs[0].WorkflowID) } + +// Verifies that the notifier_helper's `handleWorkflows` provides the local & remote reusable workflow expansion +// routines to the jobparser, and that data flows into them accurately. +func TestActionsNotifier_ExpandReusableWorkflow(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + var localReusableCalled []string + var localReusableCalledGitCommit []*git.Commit + defer test.MockVariableValue(&expandLocalReusableWorkflows, + func(commit *git.Commit) jobparser.LocalWorkflowFetcher { + return func(job *jobparser.Job, path string) ([]byte, error) { + localReusableCalledGitCommit = append(localReusableCalledGitCommit, commit) + localReusableCalled = append(localReusableCalled, path) + return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil + } + })() + remoteReusableCalled := []*model.NonLocalReusableWorkflowReference{} + defer test.MockVariableValue(&expandInstanceReusableWorkflows, + func(ctx context.Context) jobparser.InstanceWorkflowFetcher { + return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) { + remoteReusableCalled = append(remoteReusableCalled, ref) + return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil + } + })() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3}) + + dw := &actions_module.DetectedWorkflow{ + Content: []byte("{ on: pull_request, jobs: { j1: { uses: \"./.forgejo/workflows/reusable-path.yml\" }, j2: { uses: \"some-org/some-repo/.forgejo/workflows/reusable-path.yml@main\" }} }"), + } + testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync) + + runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ + RepoID: repo.ID, + }) + require.NoError(t, err) + require.Len(t, runs, 1) + run := runs[0] + assert.EqualValues(t, 0, run.PreExecutionErrorCode, "pre execution error details: %#v", run.PreExecutionErrorDetails) + + require.Len(t, localReusableCalled, 1, "localReusableCalled") + require.Len(t, localReusableCalledGitCommit, 1, "localReusableCalledGitCommit") + require.Len(t, remoteReusableCalled, 1, "remoteReusableCalled") + + assert.Equal(t, "./.forgejo/workflows/reusable-path.yml", localReusableCalled[0]) + assert.Equal(t, "test", localReusableCalledGitCommit[0].CommitMessage) + assert.Equal(t, &model.NonLocalReusableWorkflowReference{ + Org: "some-org", + Repo: "some-repo", + Filename: "reusable-path.yml", + Ref: "main", + GitPlatform: "forgejo", + }, remoteReusableCalled[0]) +} diff --git a/services/actions/reusable_workflows.go b/services/actions/reusable_workflows.go new file mode 100644 index 0000000000..c3fd0b08c1 --- /dev/null +++ b/services/actions/reusable_workflows.go @@ -0,0 +1,154 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "context" + "fmt" + "io" + + repo_model "forgejo.org/models/repo" + user_model "forgejo.org/models/user" + "forgejo.org/modules/git" + "forgejo.org/modules/gitrepo" + + "code.forgejo.org/forgejo/runner/v12/act/jobparser" + "code.forgejo.org/forgejo/runner/v12/act/model" +) + +type CleanupFunc func() + +// Evaluate whether we want to expand reusable workflows to their internal workflows. If the job has defined `runs-on` +// labels, then we will not expand them -- this maintains the legacy behaviour from before reusable workflow expansion. +// If `runs-on` is absent then we will attempt to expand the job. +func expandForJob(job *jobparser.Job) bool { + return len(job.RunsOn()) == 0 +} + +// Provide a closure for `jobparser.ExpandLocalReusableWorkflows` which resolves reusable workflow references local to +// the given commit. A reusable workflow reference is a job with a `uses: ./.forgejo/workflows/some-path.yaml`, and +// resolving it involves reading the target file in the target commit and returning the file contents. +// +// See `expandForJob` for information about jobs that are exempt from expansion. +var expandLocalReusableWorkflows = func(commit *git.Commit) jobparser.LocalWorkflowFetcher { + return func(job *jobparser.Job, path string) ([]byte, error) { + if !expandForJob(job) { + return nil, jobparser.ErrUnsupportedReusableWorkflowFetch + } + + blob, err := commit.GetBlobByPath(path) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to access path %s: %w", path, err) + } + + reader, err := blob.DataAsync() + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to read path %s: %w", path, err) + } + + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to read path %s: %w", path, err) + } + + return content, nil + } +} + +// Provide a closure for `jobparser.ExpandLocalReusableWorkflows` which resolves reusable workflow references local to +// the given repo & commit SHA. This variation lazily opens the target git repo and reads the commit only when a local +// reusable workflow is needed, and then caches the commit if multiple workflows need to be read. A cleanup function is +// also returned that will close the open git repo, and should be `defer` executed. +// +// See `expandForJob` for information about jobs that are exempt from expansion. +var lazyRepoExpandLocalReusableWorkflow = func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) { + // In the event that local reusable workflows (eg. `uses: ./.forgejo/workflows/reusable.yml`) are present, we'll + // need to read the commit of the repo to resolve that reference. But most workflows don't do this, so save the + // effort of opening the git repo and fetching the schedule's `CommitSHA` commit if it's not necessary by wrapping + // that logic in a caching closure, `getGitCommit`. + var innerFetcher jobparser.LocalWorkflowFetcher + var gitRepo *git.Repository + cleanupFunc := func() { + if gitRepo != nil { + gitRepo.Close() + } + } + fetcher := func(job *jobparser.Job, path string) ([]byte, error) { + if !expandForJob(job) { + return nil, jobparser.ErrUnsupportedReusableWorkflowFetch + } + if innerFetcher != nil { + content, err := innerFetcher(job, path) + return content, err + } + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to get repo: %w", err) + } + gitRepo, err = gitrepo.OpenRepository(ctx, repo) // ensure this keeps reference to the outer closure's `gitRepo`, not a local definition + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to open repo: %w", err) + } + commit, err := gitRepo.GetCommit(commitSHA) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to open commit %q on repo %s: %w", commitSHA, repo.FullName(), err) + } + innerFetcher = expandLocalReusableWorkflows(commit) + content, err := innerFetcher(job, path) + return content, err + } + return fetcher, cleanupFunc +} + +// Standard function for `jobparser.ExpandInstanceReusableWorkflows` which resolves reusable workflow references on the +// same Forgejo instance, but in a specific repo. For example, `uses: +// some-org/some-repo/.forgejo/workflows/some-path.yaml@ref`. Resolving it involves reading the target file in the +// target repo & commit and returning the file contents. +// +// See `expandForJob` for information about jobs that are exempt from expansion. +var expandInstanceReusableWorkflows = func(ctx context.Context) jobparser.InstanceWorkflowFetcher { + return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) { + if !expandForJob(job) { + return nil, jobparser.ErrUnsupportedReusableWorkflowFetch + } + + owner, err := user_model.GetUserByName(ctx, ref.Org) + // Reusable workflows don't currently support access to any private repos -- that's implemented here as well, + // although in the future it might be possible to use context information about the executing workflow to + // broaden access (eg. repos within an org could access other repos within the same org, perhaps). + if (err != nil && user_model.IsErrUserNotExist(err)) || !owner.Visibility.IsPublic() { + // Same error message is returned for non-existing & non-visible to avoid information leak + return nil, fmt.Errorf("expanding reusable workflow failed to access user %s: user does not exist", ref.Org) + } else if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to access user %s: %w", ref.Org, err) + } + + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, ref.Repo) + if (err != nil && repo_model.IsErrRepoNotExist(err)) || repo.IsPrivate { + // Same error message is returned for non-existing & non-visible to avoid information leak + return nil, fmt.Errorf("expanding reusable workflow failed to access repo %s: repo does not exist", ref.Repo) + } else if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to access repo %s: %w", ref.Repo, err) + } + + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to open repo %s: %w", repo.FullName(), err) + } + defer gitRepo.Close() + + commitID, err := gitRepo.GetRefCommitID(ref.Ref) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to resolve reference %q on repo %s: %w", ref.Ref, repo.FullName(), err) + } + + commit, err := gitRepo.GetCommit(commitID) + if err != nil { + return nil, fmt.Errorf("expanding reusable workflow failed to open commit %q on repo %s: %w", commitID, repo.FullName(), err) + } + + data, err := expandLocalReusableWorkflows(commit)(job, ref.FilePath()) + return data, err + } +} diff --git a/services/actions/reusable_workflows_test.go b/services/actions/reusable_workflows_test.go new file mode 100644 index 0000000000..082aad1d12 --- /dev/null +++ b/services/actions/reusable_workflows_test.go @@ -0,0 +1,203 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "forgejo.org/models/unittest" + "forgejo.org/modules/git" + "forgejo.org/modules/setting" + + "code.forgejo.org/forgejo/runner/v12/act/jobparser" + "code.forgejo.org/forgejo/runner/v12/act/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +const testWorkflow string = `on: + workflow_call: + inputs: + example-string-required: + required: true + type: string + +name: test +jobs: + job1: + name: "job1 (local)" + runs-on: ubuntu-slim + steps: + - name: Echo inputs + run: | + echo example-string-required="${{ inputs.example-string-required }}" + +` + +func TestExpandForJob(t *testing.T) { + job := jobparser.Job{} + + err := yaml.Unmarshal([]byte("{ name: job1 }"), &job) + require.NoError(t, err) + assert.True(t, expandForJob(&job)) + + err = yaml.Unmarshal([]byte("{ name: job1, runs-on: ubuntu-latest }"), &job) + require.NoError(t, err) + assert.False(t, expandForJob(&job)) + + err = yaml.Unmarshal([]byte("{ name: job1, runs-on: [x64, ubuntu-latest] }"), &job) + require.NoError(t, err) + assert.False(t, expandForJob(&job)) +} + +func TestExpandLocalReusableWorkflows(t *testing.T) { + gitRepo, err := git.OpenRepository(git.DefaultContext, "./TestExpandLocalReusableWorkflows") + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit("e3868ecb4f8b483fc0bdd422561bf0062a7df907") + require.NoError(t, err) + + fetcher := expandLocalReusableWorkflows(commit) + require.NotNil(t, fetcher) + + t.Run("successful fetch", func(t *testing.T) { + content, err := fetcher(&jobparser.Job{}, "./.forgejo/workflows/reusable-1.yml") + require.NoError(t, err) + assert.Equal(t, testWorkflow, string(content)) + }) + + t.Run("file not exist", func(t *testing.T) { + _, err = fetcher(&jobparser.Job{}, "./forgejo/workflows/reusable-2.yml") + require.ErrorContains(t, err, "expanding reusable workflow failed to access path ./forgejo/workflows/reusable-2.yml: object does not exist") + }) + + t.Run("do not expand due to runs-on", func(t *testing.T) { + jobWithRunsOn := jobparser.Job{} + err = yaml.Unmarshal([]byte("{ name: job1, runs-on: ubuntu-latest }"), &jobWithRunsOn) + require.NoError(t, err) + assert.False(t, expandForJob(&jobWithRunsOn)) + _, err = fetcher(&jobWithRunsOn, "./.forgejo/workflows/reusable-1.yml") + require.ErrorIs(t, jobparser.ErrUnsupportedReusableWorkflowFetch, err) + }) +} + +func replaceTestRepo(t *testing.T, owner, repo, replacement string) { + t.Helper() + + // Copy the repository into the target path that `gitrepo.OpenRepository` will look for it. + repoPath := filepath.Join(setting.RepoRootPath, strings.ToLower(owner), strings.ToLower(repo)+".git") + err := os.RemoveAll(repoPath) // there's a default repo copied here by the fixture setup that we want to replace + require.NoError(t, err) + err = os.CopyFS(repoPath, os.DirFS(replacement)) + require.NoError(t, err) +} + +func TestLazyRepoExpandLocalReusableWorkflows(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Shouldn't need valid content if we never call the lazy evaluator + lazy1, cleanup := lazyRepoExpandLocalReusableWorkflow(t.Context(), -123456, "this is not a valid commit SHA") + assert.NotNil(t, lazy1) + assert.NotNil(t, cleanup) + cleanup() + + replaceTestRepo(t, "user2", "repo1", "./TestExpandLocalReusableWorkflows") + + lazy2, cleanup := lazyRepoExpandLocalReusableWorkflow(t.Context(), 1, "e3868ecb4f8b483fc0bdd422561bf0062a7df907") + assert.NotNil(t, lazy2) + assert.NotNil(t, cleanup) + content, err := lazy2(&jobparser.Job{}, "./.forgejo/workflows/reusable-1.yml") + require.NoError(t, err) + assert.Equal(t, testWorkflow, string(content)) + cleanup() +} + +func TestExpandInstanceReusableWorkflows(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + tests := []struct { + name string + ref *model.NonLocalReusableWorkflowReference + errIs error + errorContains string + repo string + hasRunsOn bool + }{ + { + name: "hasRunsOn", + hasRunsOn: true, + ref: &model.NonLocalReusableWorkflowReference{}, + errIs: jobparser.ErrUnsupportedReusableWorkflowFetch, + }, + { + name: "non-existent owner", + ref: &model.NonLocalReusableWorkflowReference{ + Org: "owner-does-not-exist", + }, + errorContains: "owner-does-not-exist: user does not exist", + }, + { + name: "non-public owner", + ref: &model.NonLocalReusableWorkflowReference{ + Org: "user33", + }, + errorContains: "user33: user does not exist", + }, + { + name: "non-existent repo", + ref: &model.NonLocalReusableWorkflowReference{ + Org: "user2", + Repo: "repo10000", + }, + errorContains: "repo10000: repo does not exist", + }, + { + name: "non-public repo", + ref: &model.NonLocalReusableWorkflowReference{ + Org: "user2", + Repo: "repo2", + }, + errorContains: "repo2: repo does not exist", + }, + { + name: "public repo", + ref: &model.NonLocalReusableWorkflowReference{ + Org: "user2", + Repo: "repo1", + GitPlatform: "forgejo", + Filename: "reusable-1.yml", + Ref: "main", + }, + repo: "./TestExpandLocalReusableWorkflows", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.repo != "" { + replaceTestRepo(t, tt.ref.Org, tt.ref.Repo, tt.repo) + } + + job := jobparser.Job{} + if tt.hasRunsOn { + err := yaml.Unmarshal([]byte("{ name: job1, runs-on: ubuntu-latest }"), &job) + require.NoError(t, err) + } + fetcher := expandInstanceReusableWorkflows(t.Context()) + content, err := fetcher(&job, tt.ref) + if tt.errIs != nil { + require.ErrorIs(t, err, tt.errIs) + } else if tt.errorContains != "" { + require.ErrorContains(t, err, tt.errorContains) + } else { + require.NoError(t, err) + assert.Equal(t, testWorkflow, string(content)) + } + }) + } +} diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 17a5484551..7c2f85ba94 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -169,6 +169,11 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) } } + // In the event that local reusable workflows (eg. `uses: ./.forgejo/workflows/reusable.yml`) are present, we'll + // need to read the commit of the schedule to resolve that reference: + expandLocalReusableWorkflow, expandCleanup := lazyRepoExpandLocalReusableWorkflow(ctx, cron.RepoID, cron.CommitSHA) + defer expandCleanup() + // Parse the workflow specification from the cron schedule workflows, err := actions_module.JobParser(cron.Content, jobparser.WithVars(vars), @@ -176,6 +181,8 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs. jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.SupportIncompleteRunsOn(), + jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflow), + jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)), ) if err != nil { return err diff --git a/services/actions/schedule_tasks_test.go b/services/actions/schedule_tasks_test.go index 4e70708a5d..850288159c 100644 --- a/services/actions/schedule_tasks_test.go +++ b/services/actions/schedule_tasks_test.go @@ -4,6 +4,7 @@ package actions import ( + "context" "testing" actions_model "forgejo.org/models/actions" @@ -11,9 +12,12 @@ import ( repo_model "forgejo.org/models/repo" "forgejo.org/models/unit" "forgejo.org/models/unittest" + "forgejo.org/modules/test" "forgejo.org/modules/timeutil" webhook_module "forgejo.org/modules/webhook" + "code.forgejo.org/forgejo/runner/v12/act/jobparser" + "code.forgejo.org/forgejo/runner/v12/act/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -390,3 +394,94 @@ jobs: // first inserted it is tagged w/ incomplete_runs_on assert.Contains(t, string(job.WorkflowPayload), "incomplete_runs_on: true") } + +func TestServiceActions_ExpandReusableWorkflow(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")() + require.NoError(t, unittest.PrepareTestDatabase()) + + type callArgs struct { + repoID int64 + commitSHA string + path string + } + var localReusableCalled []*callArgs + var cleanupCallCount int + defer test.MockVariableValue(&lazyRepoExpandLocalReusableWorkflow, + func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) { + fetcher := func(job *jobparser.Job, path string) ([]byte, error) { + localReusableCalled = append(localReusableCalled, &callArgs{repoID, commitSHA, path}) + return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil + } + cleanup := func() { + cleanupCallCount++ + } + return fetcher, cleanup + })() + remoteReusableCalled := []*model.NonLocalReusableWorkflowReference{} + defer test.MockVariableValue(&expandInstanceReusableWorkflows, + func(ctx context.Context) jobparser.InstanceWorkflowFetcher { + return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) { + remoteReusableCalled = append(remoteReusableCalled, ref) + return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil + } + })() + + // Load fixtures that are corrupted and create one valid scheduled workflow + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + + workflowID := "some.yml" + schedules := []*actions_model.ActionSchedule{ + { + Title: "scheduletitle1", + RepoID: repo.ID, + OwnerID: repo.OwnerID, + WorkflowID: workflowID, + TriggerUserID: repo.OwnerID, + Ref: "branch", + CommitSHA: "fakeSHA", + Event: webhook_module.HookEventSchedule, + EventPayload: "fakepayload", + Specs: []string{"* * * * *"}, + Content: []byte( + ` +jobs: + job2: + uses: ./.forgejo/workflows/reusable.yml + job3: + uses: some-org/some-repo/.forgejo/workflows/reusable-path.yml@main +`), + }, + } + + require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) + require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules)) + require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) + _, err := db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1") + require.NoError(t, err) + + // After running startTasks an ActionRun row is created for the valid scheduled workflow + require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) + require.NoError(t, startTasks(t.Context())) + require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) + + runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ + WorkflowID: workflowID, + }) + require.NoError(t, err) + require.Len(t, runs, 1) + run := runs[0] + assert.EqualValues(t, 0, run.PreExecutionErrorCode, "pre execution error details: %#v", run.PreExecutionErrorDetails) + + require.Len(t, localReusableCalled, 1, "localReusableCalled") + require.Len(t, remoteReusableCalled, 1, "remoteReusableCalled") + + assert.Equal(t, &callArgs{4, "fakeSHA", "./.forgejo/workflows/reusable.yml"}, localReusableCalled[0]) + assert.Equal(t, 2, cleanupCallCount, "cleanupCallCount") + assert.Equal(t, &model.NonLocalReusableWorkflowReference{ + Org: "some-org", + Repo: "some-repo", + Filename: "reusable-path.yml", + Ref: "main", + GitPlatform: "forgejo", + }, remoteReusableCalled[0]) +} diff --git a/services/actions/workflows.go b/services/actions/workflows.go index fd5446a837..a08a069cc1 100644 --- a/services/actions/workflows.go +++ b/services/actions/workflows.go @@ -181,6 +181,8 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs. jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.SupportIncompleteRunsOn(), + jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflows(entry.Commit)), + jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)), ) if err != nil { return nil, nil, err diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index f42ced987a..2b770c95a1 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -1006,6 +1006,77 @@ func TestActionsWorkflowDispatchDynamicMatrix(t *testing.T) { }) } +func TestActionsWorkflowDispatchReusableWorkflow(t *testing.T) { + onApplicationRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch", + []unit_model.Type{unit_model.TypeActions}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".forgejo/workflows/dispatch.yml", + ContentReader: strings.NewReader( + "name: test\n" + + "on: [workflow_dispatch]\n" + + "jobs:\n" + + " test:\n" + + " uses: ./.forgejo/workflows/reusable.yml\n", + ), + }, + { + Operation: "create", + TreePath: ".forgejo/workflows/reusable.yml", + ContentReader: strings.NewReader( + "name: test\n" + + "on: [workflow_call]\n" + + "jobs:\n" + + " inner:\n" + + " runs-on: ubuntu-latest\n" + + " steps:\n" + + " - run: echo helloworld\n", + ), + }, + }, + ) + defer f() + + gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml") + require.NoError(t, err) + assert.Equal(t, "refs/heads/main", workflow.Ref) + assert.Equal(t, sha, workflow.Commit.ID.String()) + + inputGetter := func(key string) string { + return "" + } + + run, _, err := workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2) + require.NoError(t, err) + + var runJobs []*actions_model.ActionRunJob + db.GetEngine(t.Context()).Where("run_id=?", run.ID).Find(&runJobs) + assert.Len(t, runJobs, 2) + + var parentJob *actions_model.ActionRunJob + var childJob *actions_model.ActionRunJob + for _, j := range runJobs { + switch j.JobID { + case "test": + parentJob = j + case "test.inner": + childJob = j + } + } + assert.NotNil(t, parentJob, "parentJob") + assert.NotNil(t, childJob, "childJob") + }) +} + func TestActionsWorkflowDispatchConcurrencyGroup(t *testing.T) { onApplicationRun(t, func(t *testing.T, u *url.URL) { user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})