From f7d2f51bf77aff2ddafad983271ca8762f877108 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Mon, 29 Dec 2025 15:37:44 +0100 Subject: [PATCH] feat: support workflow inputs on expanded reusable workflows (#10614) Follow-up to #10525; adds support for `on.workflow_call.inputs` to expanded reusable workflows (when no `runs-on` is specified in a job that `uses: ...` another workflow). The majority of the work for this is done by the `jobparser` library which evaluates inputs automatically when the job is being parsed and stores those inputs on the expanded jobs as the "default" value in `on.workflow_call.inputs`. Forgejo's role here is just to to ensure that `forgejo.event_name` is set to `"workflow_call"` when a job is dispatched, which causes the runner to use the inputs that are stored -- https://code.forgejo.org/forgejo/runner/src/commit/34c20aa50f1d65e1f0265b5b5274a0bbe5db128e/act/runner/expression.go#L452-L467. ## 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/1323 ### 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/1664 - [ ] 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/10614): support workflow inputs on expanded reusable workflows Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10614 Reviewed-by: Andreas Ahlenstorf Co-authored-by: Mathieu Fenniak Co-committed-by: Mathieu Fenniak --- models/actions/run_job.go | 9 ++++++ models/actions/run_job_test.go | 37 ++++++++++++++++++++++++ services/actions/context.go | 15 ++++++++-- services/actions/context_test.go | 48 ++++++++++++++++++++++++++------ services/actions/task.go | 5 +++- 5 files changed, 103 insertions(+), 11 deletions(-) diff --git a/models/actions/run_job.go b/models/actions/run_job.go index e3e3f4f85f..7d943f1410 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -287,3 +287,12 @@ func (job *ActionRunJob) IsWorkflowCallOuterJob() (bool, error) { } return jobWorkflow.Metadata.WorkflowCallID != "", nil } + +// Check whether the target job was generated as a result of expanding a reusable workflow. +func (job *ActionRunJob) IsWorkflowCallInnerJob() (bool, error) { + jobWorkflow, err := job.DecodeWorkflowPayload() + if err != nil { + return false, fmt.Errorf("failure decoding workflow payload: %w", err) + } + return jobWorkflow.Metadata.WorkflowCallParent != "", nil +} diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go index 82b055d3fa..231a17f463 100644 --- a/models/actions/run_job_test.go +++ b/models/actions/run_job_test.go @@ -196,3 +196,40 @@ func TestActionRunJob_IsWorkflowCallOuterJob(t *testing.T) { }) } } + +func TestActionRunJob_IsWorkflowCallInnerJob(t *testing.T) { + tests := []struct { + name string + job ActionRunJob + isWorkflowCallInnerJob bool + errContains string + }{ + { + name: "normal workflow", + job: ActionRunJob{WorkflowPayload: []byte("on: [workflow_dispatch]\nname: workflow")}, + isWorkflowCallInnerJob: false, + }, + { + name: "inner job", + job: ActionRunJob{WorkflowPayload: []byte("on:\n workflow_call:\nname: workflow\n__metadata:\n workflow_call_parent: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed\n")}, + isWorkflowCallInnerJob: 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) { + isWorkflowCallInnerJob, err := tt.job.IsWorkflowCallInnerJob() + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + } else { + require.NoError(t, err) + assert.Equal(t, tt.isWorkflowCallInnerJob, isWorkflowCallInnerJob) + } + }) + } +} diff --git a/services/actions/context.go b/services/actions/context.go index 4e4bfd75ea..d982b231ff 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -80,9 +80,20 @@ func generateGiteaContextForRun(run *actions_model.ActionRun) *model.GithubConte // GenerateGiteaContext generate the gitea context without token and gitea_runtime_token // job can be nil when generating a context for parsing workflow-level expressions -func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) map[string]any { +func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) { gitContextObj := generateGiteaContextForRun(run) + if job != nil { + // Setting the `github.event_name` value to `workflow_call` while executing a reusable workflow's inner job + // causes forgejo-runner to read `on.workflow_call.inputs` and populate its values into the `inputs` context. + workflowCall, err := job.IsWorkflowCallInnerJob() + if err != nil { + return nil, fmt.Errorf("failed to inspect workflow call state: %w", err) + } else if workflowCall { + gitContextObj.EventName = "workflow_call" + } + } + gitContext, _ := githubContextToMap(gitContextObj) // standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context @@ -108,7 +119,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio gitContext["run_attempt"] = fmt.Sprint(job.Attempt) } - return gitContext + return gitContext, nil } func githubContextToMap(gitContext *model.GithubContext) (map[string]any, error) { diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 544543a9c3..665f207219 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -66,7 +66,8 @@ func TestGenerateGiteaContext(t *testing.T) { EventPayload: `{"repository": {"name": "testrepo"}}`, } - context := GenerateGiteaContext(run, nil) + context, err := GenerateGiteaContext(run, nil) + require.NoError(t, err) assert.Equal(t, "testuser", context["actor"]) assert.Equal(t, setting.AppURL+"api/v1", context["api_url"]) @@ -121,13 +122,15 @@ func TestGenerateGiteaContext(t *testing.T) { } job := &actions_model.ActionRunJob{ - ID: 100, - RunID: 1, - JobID: "test-job", - Attempt: 1, + ID: 100, + RunID: 1, + JobID: "test-job", + Attempt: 1, + WorkflowPayload: []byte("on: [push]"), } - context := GenerateGiteaContext(run, job) + context, err := GenerateGiteaContext(run, job) + require.NoError(t, err) assert.Equal(t, "test-job", context["job"]) assert.Equal(t, "1", context["run_id"]) @@ -166,7 +169,8 @@ func TestGenerateGiteaContext(t *testing.T) { EventPayload: string(payloadBytes), } - context := GenerateGiteaContext(run, nil) + context, err := GenerateGiteaContext(run, nil) + require.NoError(t, err) assert.Equal(t, "main", context["base_ref"]) assert.Equal(t, "feature-branch", context["head_ref"]) @@ -207,7 +211,8 @@ func TestGenerateGiteaContext(t *testing.T) { EventPayload: string(payloadBytes), } - context := GenerateGiteaContext(run, nil) + context, err := GenerateGiteaContext(run, nil) + require.NoError(t, err) assert.Equal(t, "main", context["base_ref"]) assert.Equal(t, "feature-branch", context["head_ref"]) @@ -218,6 +223,33 @@ func TestGenerateGiteaContext(t *testing.T) { assert.Equal(t, "branch", context["ref_type"]) assert.Equal(t, "testowner/testrepo/.github/workflows/test-workflow.yml@refs/heads/main", context["workflow_ref"]) }) + + t.Run("workflow_call job", func(t *testing.T) { + run := &actions_model.ActionRun{ + ID: 1, + Index: 42, + TriggerUser: testUser, + Repo: testRepo, + TriggerEvent: "push", + Ref: "refs/heads/main", + CommitSHA: "abc123def456", + WorkflowID: "test-workflow", + EventPayload: `{}`, + } + + job := &actions_model.ActionRunJob{ + ID: 100, + RunID: 1, + JobID: "test-job", + Attempt: 1, + WorkflowPayload: []byte("on: { workflow_call: { inputs: {} } }\n__metadata:\n workflow_call_parent: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed\n"), + } + + context, err := GenerateGiteaContext(run, job) + require.NoError(t, err) + + assert.Equal(t, "workflow_call", context["event_name"]) + }) } func TestGenerateGiteaContextForRun(t *testing.T) { diff --git a/services/actions/task.go b/services/actions/task.go index f4ad230b0c..277c2f6ca0 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -88,7 +88,10 @@ func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) return nil, err } - gitCtx := GenerateGiteaContext(t.Job.Run, t.Job) + gitCtx, err := GenerateGiteaContext(t.Job.Run, t.Job) + if err != nil { + return nil, err + } gitCtx["token"] = t.Token gitCtx["gitea_runtime_token"] = giteaRuntimeToken