From 0d09986790cc905998b60932c530062214282bca Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Sun, 27 Sep 2026 11:08:46 -0600 Subject: [PATCH] fix(actions): keep the caller's event and inputs in reusable workflow jobs (#39452) Fixes https://github.com/go-gitea/gitea/issues/38705 Fixes https://gitea.com/gitea/runner/issues/1232 Jobs of a called workflow saw `gitea.event_name` as `workflow_call` and `gitea.event.inputs` replaced by the caller's `with:`, so a condition like `gitea.event_name == 'push'` never held in them, and a dispatched run's own inputs were lost there. They now keep the caller's trigger event and their `inputs` are the run's `workflow_dispatch` inputs overlaid with the caller's `with:`, as on GitHub. For example, with `workflow_dispatch` inputs `{target: prod, debug: true}` and caller's `with: {target: dev}`, the called workflow's `inputs` are `{target: dev, debug: true}`. A runner cannot resolve these inputs itself, so they are sent in a new `gitea_workflow_call` context entry, with the original event name and inputs for the runner to restore. For more details, see the runner PR: https://gitea.com/gitea/runner/pulls/1250 Co-authored-by: silverwind --- models/actions/run_job.go | 2 +- services/actions/context.go | 43 ++++----------------- services/actions/context_test.go | 11 +++++- services/actions/helper.go | 44 ++++++++++++++++----- services/actions/helper_test.go | 55 +++++++++++++++++++++++++++ services/actions/reusable_workflow.go | 12 +++--- services/actions/task.go | 24 ++++++++++++ 7 files changed, 137 insertions(+), 54 deletions(-) diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 13a77ea954f..bcf111473a6 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -116,7 +116,7 @@ type ActionRunJob struct { // - JSON object : explicit mapping {alias: source_name}; names only, no values. // Only set when IsReusableCaller is true. CallSecrets string `xorm:"LONGTEXT"` - // CallPayload is the JSON-encoded WorkflowCallPayload exposed to children as gitea.event. + // CallPayload is the JSON-encoded WorkflowCallPayload. // Populated atomically with IsExpanded at the end of expandReusableWorkflowCaller. // Only set when IsReusableCaller is true. CallPayload string `xorm:"LONGTEXT"` diff --git a/services/actions/context.go b/services/actions/context.go index 7755ad3e5c2..be814b1de81 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -25,7 +25,6 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/optional" "gitea.dev/modules/setting" - api "gitea.dev/modules/structs" ) type GiteaContext map[string]any @@ -113,31 +112,6 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att if job != nil { gitContext["job"] = job.JobID gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10) - - if job.ParentJobID > 0 { - // Inject the caller's resolved workflow_call inputs into gitea.event.inputs. - // The rest of gitea.event stays as the caller's actual trigger event (push/pull_request/etc.) - // to match GitHub's semantics (see https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#github-context). - // FIXME: If the run is triggered by "workflow_dispatch", the original inputs of "workflow_dispatch" will be overridden. - // If necessary, the caller can send these values to the called workflow via `with:`. - caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID) - if err != nil { - log.Error("GenerateGiteaContext: load caller job %d of job %d: %v", job.ParentJobID, job.ID, err) - } else if caller.CallPayload != "" { - var cp api.WorkflowCallPayload - if err := json.Unmarshal([]byte(caller.CallPayload), &cp); err != nil { - log.Error("GenerateGiteaContext: decode CallPayload of caller %d: %v", caller.ID, err) - } else if cp.Inputs != nil { - event["inputs"] = cp.Inputs - } - } - - // Override gitea.event_name to "workflow_call", so that the runner-side `getEvaluatorInputs` can get inputs from event["inputs"]. - // https://gitea.com/gitea/runner/src/commit/0b9f251b6abb30d5f292a49cfe0c611f7c26d857/act/runner/expression.go#L509 - // FIXME: The trade-off is that `${{ gitea.event_name }}` inside a reusable workflow's child job reads "workflow_call" - // instead of the caller's real trigger event name (push/pull_request/etc.) This is a small deviation from GitHub spec. - gitContext["event_name"] = "workflow_call" - } } if attempt == nil { @@ -288,15 +262,14 @@ func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.Act if err != nil { return nil, err } - inputs := map[string]any{} - if caller.CallPayload != "" { - var p api.WorkflowCallPayload - if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil { - return nil, fmt.Errorf("decode caller payload: %w", err) - } - if p.Inputs != nil { - inputs = p.Inputs - } + workflowCallInputs, err := decodeWorkflowCallInputs(caller) + if err != nil { + return nil, err + } + + inputs, err := calledWorkflowInputs(ctx, caller.Run, caller, workflowCallInputs) + if err != nil { + return nil, err } // See `on.workflow_call.outputs..value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 494e88a9b24..b16f1247b1f 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -293,8 +293,10 @@ func TestComputeReusableCallerOutputs(t *testing.T) { assert.Equal(t, map[string]string{"result": "bar"}, out) }) - t.Run("CallPayload inputs reachable in output expression", func(t *testing.T) { + t.Run("CallPayload and dispatch inputs reachable in output expression", func(t *testing.T) { run := insertRun(t, "payload-out.yaml") + run.Event, run.EventPayload = "workflow_dispatch", `{"inputs":{"target":"prod"}}` + require.NoError(t, actions_model.UpdateRun(ctx, run, "event", "event_payload")) payload, err := json.Marshal(api.WorkflowCallPayload{ Inputs: map[string]any{"env": "staging"}, }) @@ -307,11 +309,16 @@ func TestComputeReusableCallerOutputs(t *testing.T) { outputs: env: value: ${{ inputs.env }} + target: + value: ${{ inputs.target }} `, string(payload)) + caller.WorkflowPayload = []byte("on: {workflow_dispatch: {inputs: {target: {type: string}}}}\njobs:\n caller:\n uses: ./.gitea/workflows/callee.yml\n") + _, err = actions_model.UpdateRunJob(ctx, caller, nil, "workflow_payload") + require.NoError(t, err) out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) require.NoError(t, err) - assert.Equal(t, map[string]string{"env": "staging"}, out) + assert.Equal(t, map[string]string{"env": "staging", "target": "prod"}, out) }) t.Run("nested caller outputs propagate to outer", func(t *testing.T) { diff --git a/services/actions/helper.go b/services/actions/helper.go index 6ec6b88984a..195822291a1 100644 --- a/services/actions/helper.go +++ b/services/actions/helper.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "maps" + "strings" actions_model "gitea.dev/models/actions" actions_module "gitea.dev/modules/actions" @@ -53,7 +55,7 @@ func dispatchInputsForRunJobs(run *actions_model.ActionRun, jobs []*actions_mode // getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation. // - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events) -// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs +// - For reusable workflow children (and nested callers), this is calledWorkflowInputs of the direct parent caller func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) { if job.ParentJobID == 0 { return dispatchInputsForJob(run, job) @@ -63,20 +65,44 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act if err != nil { return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err) } - if caller.CallPayload == "" { - // should not happen - a child job cannot reach this point if its caller's CallPayload hasn't been evaluated - return map[string]any{}, nil + workflowCallInputs, err := decodeWorkflowCallInputs(caller) + if err != nil { + return nil, err } + return calledWorkflowInputs(ctx, run, caller, workflowCallInputs) +} + +func decodeWorkflowCallInputs(caller *actions_model.ActionRunJob) (map[string]any, error) { var p api.WorkflowCallPayload - if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil { - return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err) - } - if p.Inputs == nil { - return map[string]any{}, nil + if caller.CallPayload != "" { + if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil { + return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err) + } } return p.Inputs, nil } +// calledWorkflowInputs overlays the run's dispatch inputs with `workflowCallInputs`, as GitHub does. +func calledWorkflowInputs(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, workflowCallInputs map[string]any) (map[string]any, error) { + top := caller + for run.Event == "workflow_dispatch" && top.ParentJobID != 0 { + parent, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, top.ParentJobID) + if err != nil { + return nil, fmt.Errorf("load caller job %d: %w", top.ParentJobID, err) + } + top = parent + } + inputs, err := dispatchInputsForJob(run, top) + if err != nil { + return nil, err + } + for name, value := range workflowCallInputs { + maps.DeleteFunc(inputs, func(key string, _ any) bool { return strings.EqualFold(key, name) }) // input names are case-insensitive + inputs[name] = value + } + return inputs, nil +} + // pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one. func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) { if run.TriggerEvent != actions_module.GithubEventPullRequestTarget { diff --git a/services/actions/helper_test.go b/services/actions/helper_test.go index 5475775ed5b..eb7c822119e 100644 --- a/services/actions/helper_test.go +++ b/services/actions/helper_test.go @@ -7,6 +7,8 @@ import ( "testing" actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + "gitea.dev/models/unittest" actions_module "gitea.dev/modules/actions" "gitea.dev/modules/json" api "gitea.dev/modules/structs" @@ -33,6 +35,59 @@ func TestDispatchInputsForRunJobs(t *testing.T) { assert.Equal(t, true, inputs["deploy"]) } +func TestReusableChildInputs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + const runID = 9801 + insertJob := func(jobID string, parentID int64, payload, callPayload string) *actions_model.ActionRunJob { + job := &actions_model.ActionRunJob{RunID: runID, JobID: jobID, ParentJobID: parentID, WorkflowPayload: []byte(payload), CallPayload: callPayload} + require.NoError(t, db.Insert(ctx, job)) + return job + } + caller := insertJob("caller", 0, + "on: {workflow_dispatch: {inputs: {flag: {type: boolean}, Shared: {type: string}}}}\njobs:\n caller:\n uses: ./.gitea/workflows/mid.yml\n", + `{"inputs":{"shared":"from-call","mid_only":"mid"}}`) + mid := insertJob("mid", caller.ID, "", `{"inputs":{"env":"leaf"}}`) + leaf := insertJob("leaf", mid.ID, "", "") + + dispatchRun := &actions_model.ActionRun{ID: runID, Event: "workflow_dispatch", EventPayload: `{"inputs":{"flag":"true","Shared":"from-dispatch"}}`} + pushRun := &actions_model.ActionRun{ID: runID, Event: "push", EventPayload: `{}`} + + t.Run("dispatch inputs overlaid case-insensitively with the caller's with", func(t *testing.T) { + inputs, err := getInputsForJob(ctx, dispatchRun, mid) + require.NoError(t, err) + assert.Equal(t, map[string]any{"flag": true, "shared": "from-call", "mid_only": "mid"}, inputs) + }) + + t.Run("task context of a nested child keeps the older runners' form and carries the original event", func(t *testing.T) { + leaf.Run = dispatchRun + gitCtx := GiteaContext{ + "event_name": "workflow_dispatch", + "event": map[string]any{"inputs": map[string]any{"flag": "true", "Shared": "from-dispatch"}}, + } + require.NoError(t, setCalledWorkflowContext(ctx, leaf, gitCtx)) + inputs := map[string]any{"flag": true, "Shared": "from-dispatch", "env": "leaf"} + assert.Equal(t, "workflow_call", gitCtx["event_name"]) + assert.Equal(t, map[string]any{"inputs": inputs}, gitCtx["event"]) + assert.Equal(t, map[string]any{ + "original_event_name": "workflow_dispatch", + "original_event_inputs": map[string]any{"flag": "true", "Shared": "from-dispatch"}, + "inputs": inputs, + }, gitCtx["gitea_workflow_call"]) + }) + + t.Run("task context of a non-dispatch run", func(t *testing.T) { + mid.Run = pushRun + gitCtx := GiteaContext{"event_name": "push", "event": map[string]any{}} + require.NoError(t, setCalledWorkflowContext(ctx, mid, gitCtx)) + inputs := map[string]any{"shared": "from-call", "mid_only": "mid"} + assert.Equal(t, "workflow_call", gitCtx["event_name"]) + assert.Equal(t, map[string]any{"inputs": inputs}, gitCtx["event"]) + assert.Equal(t, map[string]any{"original_event_name": "push", "inputs": inputs}, gitCtx["gitea_workflow_call"]) + }) +} + func TestPullRequestTargetBaseSHA(t *testing.T) { prPayload := func(baseSHA string) string { payload, err := json.Marshal(api.PullRequestPayload{ diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go index 641cb21efdc..0f1fbb4ffbd 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -272,6 +272,10 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action return fmt.Errorf("caller %q inputs: %w", caller.JobID, err) } } + jobInputs, err := calledWorkflowInputs(ctx, run, caller, workflowCallInputs) + if err != nil { + return err + } // 7. Build CallPayload (persisted in step 9). callPayload, err := (&api.WorkflowCallPayload{ @@ -303,7 +307,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action } // 9. We own the expansion: insert the direct children. - if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil { + if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, jobInputs); err != nil { // On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless. return errors.Join(err, undoExpansion(ctx, caller)) } @@ -335,13 +339,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att } } - // Parse the called workflow with the caller's `inputs` gitCtx := GenerateGiteaContext(ctx, run, attempt, nil) - if event, ok := gitCtx["event"].(map[string]any); ok { - event["inputs"] = inputs - } - gitCtx["event_name"] = "workflow_call" - childWorkflows, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(gitCtx.ToGitHubContext()), diff --git a/services/actions/task.go b/services/actions/task.go index c744306ecb6..e5f39349124 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -193,6 +193,11 @@ func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*str } gitCtx := GenerateGiteaContext(ctx, t.Job.Run, nil, t.Job) + if t.Job.ParentJobID > 0 { + if err := setCalledWorkflowContext(ctx, t.Job, gitCtx); err != nil { + return nil, err + } + } gitCtx["token"] = t.Token gitCtx["gitea_runtime_token"] = giteaRuntimeToken @@ -213,3 +218,22 @@ func findTaskNeeds(ctx context.Context, taskJob *actions_model.ActionRunJob) (ma } return ret, nil } + +// setCalledWorkflowContext rewrites the context for older runners, newer runners undo it via `gitea_workflow_call`. +func setCalledWorkflowContext(ctx context.Context, job *actions_model.ActionRunJob, gitCtx GiteaContext) error { + inputs, err := getInputsForJob(ctx, job.Run, job) + if err != nil { + return err + } + event, _ := gitCtx["event"].(map[string]any) + workflowCall := map[string]any{"original_event_name": gitCtx["event_name"], "inputs": inputs} + if eventInputs, ok := event["inputs"]; ok { + workflowCall["original_event_inputs"] = eventInputs + } + gitCtx["gitea_workflow_call"] = workflowCall + gitCtx["event_name"] = "workflow_call" + if event != nil { + event["inputs"] = inputs + } + return nil +}