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/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/10578): <!--number 10578 --><!--line 0 --><!--description c3VwcG9ydCB3b3JrZmxvdyBvdXRwdXRzIG9uIGV4cGFuZGVkIHJldXNhYmxlIHdvcmtmbG93cw==-->support workflow outputs on expanded reusable workflows<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10578
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2025-12-28 20:05:13 +01:00 committed by Mathieu Fenniak
parent 85dacd8e50
commit fb35abb7e1
5 changed files with 86 additions and 7 deletions

View file

@ -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)
}

View file

@ -0,0 +1,6 @@
# Case w/ action_run_job.id = 601
-
id: 100
task_id: 100
output_key: my_output
output_value: 'abcdefghijklmnopqrstuvwxyz'

View file

@ -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

View file

@ -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)
}

View file

@ -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 {