From fb35abb7e1d75c89a50ba36b8a66a0190aa7abb0 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Sun, 28 Dec 2025 20:05:13 +0100 Subject: [PATCH] feat: support workflow outputs on expanded reusable workflows (#10578) Follow-up to #10525; adds support for `on.workflow_call.outputs` to expanded reusable workflows (when no `runs-on` is specified on a job that `uses: ...` another workflow). When all the inner jobs of a workflow call complete, `on.workflow_call.outputs` is evaluated and the related outputs are stored on the outer job's `ActionTask`. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). - **end-to-end testing**: https://code.forgejo.org/forgejo/end-to-end/pulls/1322 ### Documentation - [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - https://codeberg.org/forgejo/docs/pulls/1661 - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/10578): support workflow outputs on expanded reusable workflows Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10578 Reviewed-by: Andreas Ahlenstorf Co-authored-by: Mathieu Fenniak Co-committed-by: Mathieu Fenniak --- models/actions/run_job.go | 10 +++-- .../action_task_output.yml | 6 +++ .../action_variable.yml | 22 +++++++++ services/actions/job_emitter.go | 45 ++++++++++++++++++- services/actions/job_emitter_test.go | 10 ++++- 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 services/actions/Test_tryHandleWorkflowCallOuterJob/action_task_output.yml create mode 100644 services/actions/Test_tryHandleWorkflowCallOuterJob/action_variable.yml diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 94a9f11ed6..e3e3f4f85f 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -234,7 +234,9 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status { } } -func (job *ActionRunJob) decodeWorkflowPayload() (*jobparser.SingleWorkflow, error) { +// Retrieves the parsed workflow for this specific job. This field is often accessed multiple times in succession, so +// the parsed content is cached in-memory on the `ActionRunJob` instance. +func (job *ActionRunJob) DecodeWorkflowPayload() (*jobparser.SingleWorkflow, error) { if job.workflowPayloadDecoded != nil { return job.workflowPayloadDecoded, nil } @@ -259,7 +261,7 @@ func (job *ActionRunJob) ClearCachedWorkflowPayload() { // then regenerated and deleted. If it is incomplete, and if the information is available, the specific job and/or // output that causes it to be incomplete will be returned as well. func (job *ActionRunJob) IsIncompleteMatrix() (bool, *jobparser.IncompleteNeeds, error) { - jobWorkflow, err := job.decodeWorkflowPayload() + jobWorkflow, err := job.DecodeWorkflowPayload() if err != nil { return false, nil, fmt.Errorf("failure decoding workflow payload: %w", err) } @@ -269,7 +271,7 @@ func (job *ActionRunJob) IsIncompleteMatrix() (bool, *jobparser.IncompleteNeeds, // Checks whether the target job has a `runs-on` field with an expression that requires an input from another job. The // job will be blocked until the other job is complete, and then regenerated and deleted. func (job *ActionRunJob) IsIncompleteRunsOn() (bool, *jobparser.IncompleteNeeds, *jobparser.IncompleteMatrix, error) { - jobWorkflow, err := job.decodeWorkflowPayload() + jobWorkflow, err := job.DecodeWorkflowPayload() if err != nil { return false, nil, nil, fmt.Errorf("failure decoding workflow payload: %w", err) } @@ -279,7 +281,7 @@ func (job *ActionRunJob) IsIncompleteRunsOn() (bool, *jobparser.IncompleteNeeds, // 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() + jobWorkflow, err := job.DecodeWorkflowPayload() if err != nil { return false, fmt.Errorf("failure decoding workflow payload: %w", err) } diff --git a/services/actions/Test_tryHandleWorkflowCallOuterJob/action_task_output.yml b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_task_output.yml new file mode 100644 index 0000000000..9bf82649c1 --- /dev/null +++ b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_task_output.yml @@ -0,0 +1,6 @@ +# Case w/ action_run_job.id = 601 +- + id: 100 + task_id: 100 + output_key: my_output + output_value: 'abcdefghijklmnopqrstuvwxyz' diff --git a/services/actions/Test_tryHandleWorkflowCallOuterJob/action_variable.yml b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_variable.yml new file mode 100644 index 0000000000..859f90ae34 --- /dev/null +++ b/services/actions/Test_tryHandleWorkflowCallOuterJob/action_variable.yml @@ -0,0 +1,22 @@ +# Case w/ action_run_job.id = 601 +- + id: 1001 + name: REPO_VAR + owner_id: 0 + repo_id: 63 + data: "this is a repo variable" + created_unix: 1737000000 +- + id: 1002 + name: ORG_VAR + owner_id: 2 + repo_id: 0 + data: "this is an org variable" + created_unix: 1737000000 +- + id: 1003 + name: GLOBAL_VAR + owner_id: 0 + repo_id: 0 + data: "this is a global variable" + created_unix: 1737000000 diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 55e05aa712..4e61029878 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -391,8 +391,49 @@ func tryHandleWorkflowCallOuterJob(ctx context.Context, job *actions_model.Actio 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{}) + // Gather all the data that is needed to perform an expression evaluation of the job's outputs: + singleWorkflow, err := job.DecodeWorkflowPayload() + if err != nil { + return nil, fmt.Errorf("failure to decode workflow payload: %w", err) + } + err = job.LoadRun(ctx) + if err != nil { + return nil, fmt.Errorf("failure to load job's run: %w", err) + } + err = job.Run.LoadRepo(ctx) + if err != nil { + return nil, fmt.Errorf("failure to load run's repo: %w", err) + } + githubContext := generateGiteaContextForRun(job.Run) + taskNeeds, err := FindTaskNeeds(ctx, job) + if err != nil { + return nil, fmt.Errorf("failure to 'needs' for job: %w", err) + } + needs := make([]string, 0, len(taskNeeds)) + jobResults := make(map[string]string, len(taskNeeds)) + jobOutputs := make(map[string]map[string]string, len(taskNeeds)) + for jobID, n := range taskNeeds { + needs = append(needs, jobID) + jobResults[jobID] = n.Result.String() + jobOutputs[jobID] = n.Outputs + } + vars, err := actions_model.GetVariablesOfRun(ctx, job.Run) + if err != nil { + return nil, fmt.Errorf("failure to 'var' for run: %w", err) + } + + // With all the required contexts, we can calculate the outputs. + outputs := jobparser.EvaluateWorkflowCallOutputs( + singleWorkflow, + githubContext, + vars, + needs, + jobResults, + jobOutputs, + ) + + // Insert a placeholder task with all the computed outputs + actionTask, err := actions_model.CreatePlaceholderTask(ctx, job, outputs) if err != nil { return nil, fmt.Errorf("failure to insert placeholder task: %w", err) } diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index 5e1885c709..0bfa10d7ea 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -440,7 +440,15 @@ func Test_tryHandleWorkflowCallOuterJob(t *testing.T) { name: "outputs for every context", runJobID: 601, updateFields: []string{"task_id"}, - outputs: map[string]string{}, + outputs: map[string]string{ + "from_inner_job": "abcdefghijklmnopqrstuvwxyz", + "from_inner_job_result": "success", + "from_forgejo_ctx": "refs/heads/main", + "from_input_ctx": "hello, world!", + "from_vars_repo": "this is a repo variable", + "from_vars_org": "this is an org variable", + "from_vars_global": "this is a global variable", + }, }, } for _, tt := range tests {