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 <me@silverwind.io>
This commit is contained in:
Zettat123
2026-09-27 11:08:46 -06:00
committed by GitHub
parent 3c5d2d1b63
commit 0d09986790
7 changed files with 137 additions and 54 deletions
+1 -1
View File
@@ -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"`
+8 -35
View File
@@ -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.<output_id>.value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability
+9 -2
View File
@@ -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) {
+35 -9
View File
@@ -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 {
+55
View File
@@ -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{
+5 -7
View File
@@ -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()),
+24
View File
@@ -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
}